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.

Merge pull request #7 from ssp-data/gh-issue-6-and-improvements

Issue #6 and improvements for stability and minor fixes

authored by

Simon Späti and committed by
GitHub
770cc2a8 7a9bf7f1

+1153 -171
+3 -1
.claude/settings.local.json
··· 2 2 "permissions": { 3 3 "allow": [ 4 4 "Bash(go build:*)", 5 - "Bash(go test:*)" 5 + "Bash(go test:*)", 6 + "Bash(make build:*)", 7 + "Bash(make test:*)" 6 8 ] 7 9 } 8 10 }
+1
.gitignore
··· 17 17 18 18 # OS 19 19 .DS_Store 20 + .codex
+28
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + # 2026-04-10 4 + - **Issue #6 verification pass** — reviewed the user report against the current code and specifically verified that startup auto-screening does not route Inbox mail to Trash in the current implementation, while manual `ToScreen` screening remains message-by-message by design 5 + - **Fix: Drafts/Spam reload off-tab folder mismatch** — reloading while viewing an off-tab folder now reloads that actual mailbox instead of the currently selected tab's folder; fixes the confusing case where Drafts could show Inbox content after pressing `R` 6 + - **Fix: committed `/` filter now clears with `esc`** — pressing `esc` now reliably clears the in-memory inbox filter even after the filter was already applied 7 + - **Help overlay improvements** — `?` help is now scrollable with `j/k`, arrow keys, and `d/u`; search begins only after pressing `/`, so opening help no longer immediately behaves like a search prompt 8 + - **Attachment workflow guidance** — startup/welcome messaging now warns when the optional inline Neovim attachment integration is unavailable; README install docs now list `yazi` and the external `custom.lua` integration as optional requirements for `<leader>a`, while clarifying that pre-send `a` still works independently 9 + - **UX hints** — inbox footer now exposes `, sort`; pre-send footer clarifies `s` as spell-check-and-edit versus plain `e` edit; compose/pre-send `ctrl+f` now shows a message when only one From identity is configured 10 + - **Fix: sender-level screening from `ToScreen`** — approving/blocking/feed/papertrail/spam on a single unmarked message in `ToScreen` now expands to all currently queued mail from that sender, matching the intended HEY-style workflow 11 + - **Safety guard: screener destinations may not point to Trash** — screening now refuses to run if `ToScreen`, `ScreenedOut`, `Feed`, `PaperTrail`, or `Spam` are configured to the same IMAP folder as Trash 12 + - **Inbox paging clarity** — the inbox header now shows the current fetch limit (`loaded/limit`) and `d/u` page movement directly, so the “only 50 emails” behavior is visible without guessing 13 + - **Discard confirmation for unsent mail** — `esc` in compose and `esc`/`x` in pre-send now ask for confirmation before dropping the message; recovery hints still point to `:recover` 14 + - **Default Inbox load raised to 200** — new configs now use `inbox_count = 200`; README, config docs, and welcome text now clarify that normal loads/auto-screening only process that loaded Inbox slice, while `:screen-all` scans the full Inbox on the IMAP server 15 + - **Compose/draft round-trip preservation** — editor/pre-send/draft/recover flows now preserve `Bcc` and selected `From`; continuing a draft also restores its attachments back into the compose session 16 + - **Correct IMAP account for Sent/Drafts** — sent copies and saved drafts now use the IMAP account that matches the selected sending identity / `[[senders]]` alias instead of always using the currently active inbox account 17 + - **Draft MIME keeps `Bcc`** — Drafts saved via IMAP now retain the `Bcc` header so reopening a draft does not silently lose hidden recipients 18 + - **Search/Everything/Thread subjects no longer mutate** — folder prefixes are now display-only in list rendering, so reply/forward/thread logic keeps using the real RFC subject 19 + - **Screener rollback safety** — screener actions now snapshot list state and roll back both list files and already-moved emails if a later move fails, keeping mailbox state and screener files consistent 20 + - **`:search` help text fixed** — the command description now correctly says it searches across configured folders, not just the current folder 21 + 22 + ## Roborev 23 + - **Security: path traversal vulnerability fixed** — inline image handling (`O` browser preview) now sanitizes `ContentID` and `Filename` from email MIME headers to prevent attackers from writing files outside `/tmp/neomd/` via malicious `cid:` references (e.g. `../../etc/cron.d/evil`); all attachment paths now use `filepath.Base()` and verify the result stays under temp directory before writing 24 + - **Fix: conversation view navigation** — pressing `T` (thread view) now correctly shows error messages and empty-result warnings; `imapSearchResults` flag is cleared immediately so the status bar appears instead of the search bar; added general Esc handler for `offTabFolder` views that preserves search context: pressing Esc from thread view returns to IMAP search results if that's where you came from (checked via `imapSearchText`), otherwise returns to active folder; `imapSearchText` is cleared when navigating away from search via tab, clicks, or go-to commands (gi/ga/etc) to prevent stale search context from affecting unrelated views; search retry errors are now visible because `imapSearchResults` is only set by the handler on success 25 + - **Fix: Work folder move guard** — `Mb` (move to Work) is now disabled when the Work folder is not configured, preventing moves to an empty folder name; previously caused silent failures 26 + - **Fix: Work folder keybindings in help** — `gb` (go to Work) and `Mb` (move to Work) now appear in the `?` help overlay and generated keybindings documentation, marked as "(if configured)" to indicate they're optional 27 + - **Test coverage: expandEnv edge cases** — added unit tests for environment variable expansion covering unset variables (silently return empty), bare `$` alone, empty `${}`, whitespace trimming, and variables with text suffixes/prefixes; documents current behavior for config password/user fields 28 + - **Fix: welcome message formatting** — onboarding screen instruction now reads clearly ("Go to Inbox tab; once screener is active, use ToScreen") instead of the previous formatting regression ("ToScreentab") 29 + 30 + 3 31 # 2026-04-09 4 32 - **Fix: non-standard IMAP/SMTP ports** — neomd now correctly handles non-standard ports (e.g., Proton Mail Bridge on `127.0.0.1:1143` and `127.0.0.1:1025`); previously hardcoded port-based logic ignored the user's `starttls` config and refused unencrypted connections to any port other than 993/143 (IMAP) or 465/587 (SMTP); new behavior: user's explicit `starttls = true` always forces STARTTLS, standard ports use their defaults (993→TLS, 143→STARTTLS, 465→TLS, 587→STARTTLS), non-standard ports default to TLS for security (user must set `starttls = true` if their provider uses STARTTLS on a custom port); fixes "refusing unencrypted connection to 127.0.0.1:1143" error reported by Proton Bridge users; comprehensive test coverage added for all port/config combinations 5 33
+16 -1
README.md
··· 101 101 102 102 **Prerequisites:** [Go 1.22+](https://go.dev/doc/install) and `make`. 103 103 104 + > [!NOTE] 105 + > **Optional attachment helpers:** 106 + > - `yazi` enables the built-in file picker used by pre-send `a` 107 + > - custom Neovim integration in `custom.lua` enables inline `<leader>a` attachment insertion inside `neomd-*.md` buffers 108 + > - without these, neomd still works; the inline Neovim attachment workflow just won't be available 109 + 104 110 ```sh 105 111 git clone https://github.com/ssp-data/neomd 106 112 cd neomd ··· 122 128 123 129 On first run, neomd: 124 130 1. Creates `~/.config/neomd/config.toml` with placeholders — fill in your IMAP/SMTP credentials 131 + - Important: Make sure that the Capitalization and naming of folder in `config.toml` is accroding to webmail IMAP, e.g. [Gmails](docs/gmail.md) uses `sent = "[Gmail]/Sent Mail"` and not `sent` etc. 125 132 2. Creates `~/.config/neomd/lists/` for screener allowlists (or uses your custom paths from config) 126 133 3. Creates any missing IMAP folders (ToScreen, Feed, PaperTrail, etc.) automatically 134 + 127 135 128 136 Neomd also runs on Android (more for fun) — see [docs/android.md](docs/android.md). 129 137 ··· 160 168 161 169 On first launch, **auto-screening is paused** because your screener lists are empty — neomd won't move anything until you've classified your first sender. Your Inbox loads normally so you can explore. 162 170 171 + By default, neomd loads and auto-screens only the newest `200` Inbox emails (`[ui].inbox_count`). This keeps startup predictable. If you want to re-screen the entire Inbox on the IMAP server, run `:screen-all` inside neomd; that scans every Inbox email, not just the loaded subset, and can take a while on large mailboxes. 172 + 163 173 **Getting started with the screener:** 164 174 165 175 1. From your Inbox, pick an email and press `I` (screen **in**) to approve the sender, or `O` (screen **out**) to block them. This creates your first screener list entry. ··· 169 179 - `O` screen **out** — sender never reaches Inbox again 170 180 - `F` **feed** — newsletters go to the Feed tab 171 181 - `P` **papertrail** — receipts go to the PaperTrail tab 172 - 4. Use `m` to mark multiple emails, then `I` to batch-approve them all at once. 182 + 4. Use `m` to mark multiple emails, then `I` to batch-approve them all at once. From the `ToScreen` folder, approving/blocking a single unmarked message now applies to all currently queued mail from that sender. 173 183 174 184 **The best part:** all classifications are saved permanently in your screener lists (`screened_in.txt`, `screened_out.txt`, etc.). An email address screened in will automatically go to your Inbox, and any email screened out will never be in your Inbox again. 175 185 ··· 178 188 > [!TIP] 179 189 > To disable auto-screening entirely, set `auto_screen_on_load = false` in `[ui]` config. Run `:debug` inside neomd if something isn't working. 180 190 191 + > [!WARNING] 192 + > `:screen-all` operates on the full Inbox mailbox on the server, not just the emails currently loaded in the UI. Use it when you intentionally want a mailbox-wide reclassification pass. 193 + 181 194 ### Screener Workflow 182 195 183 196 Find full Screener Workflow at [docs/screener.md](docs/screener.md), classification tables, and bulk re-classification instructions. ··· 190 203 ### How Sending Works 191 204 192 205 Compose in Markdown, send as `multipart/alternative` (plain text + HTML). Attachments, CC/BCC, multiple From addresses, drafts, and pre-send review are all supported. 206 + 207 + Discarding unsent mail now asks for confirmation in compose/pre-send, and `:recover` reopens the latest backup if you want to resume after an abort. 193 208 194 209 - See [docs/sending.md](docs/sending.md) for details on MIME structure, attachments, pre-send review, and drafts. 195 210 - See [docs/reading.md](docs/reading.md) for the reader: images, inline links, attachments, and navigation.
+3 -1
docs/configuration.md
··· 64 64 65 65 [ui] 66 66 theme = "dark" # dark | light | auto 67 - inbox_count = 50 67 + inbox_count = 200 # how many newest emails neomd loads per folder/reload 68 68 auto_screen_on_load = true # screen inbox automatically on every load (default true) 69 69 bg_sync_interval = 5 # background sync interval in minutes; 0 = disabled (default 5) 70 70 bulk_progress_threshold = 10 # show progress counter for batch operations larger than this (default 10) ··· 82 82 > **Gmail** uses different IMAP folder names (`[Gmail]/Sent Mail`, `[Gmail]/Trash`, etc.). See [Gmail Configuration](gmail.md) for the correct mapping. 83 83 84 84 Use an app-specific password (Gmail, Fastmail, Hostpoint, etc.) rather than your main account password. 85 + 86 + `inbox_count` is a fetch cap for normal folder loads and startup auto-screening. If you want to re-screen the entire Inbox on the IMAP server, use `:screen-all` from inside neomd; that scans every Inbox email, not just the loaded subset, and can take a while on large mailboxes. 85 87 86 88 ### Environment Variables 87 89
+3
docs/keybindings.md
··· 12 12 | Key | Action | 13 13 |-----|--------| 14 14 | `j / k` | move down / up | 15 + | `d / u` | page down / up in inbox/help | 15 16 | `gg` | jump to top | 16 17 | `G` | jump to bottom | 17 18 | `enter / l` | open email | ··· 34 35 | `gk` | go to ToScreen | 35 36 | `go` | go to ScreenedOut | 36 37 | `gw` | go to Waiting | 38 + | `gb` | go to Work (if configured) | 37 39 | `gm` | go to Someday | 38 40 | `gd` | go to Drafts | 39 41 | `ge` | go to Everything — latest 50 emails across all folders | ··· 65 67 | `Mt` | move to Trash | 66 68 | `Mo` | move to ScreenedOut | 67 69 | `Mw` | move to Waiting | 70 + | `Mb` | move to Work (if configured) | 68 71 | `Mm` | move to Someday | 69 72 | `Mk` | move to ToScreen | 70 73
+1 -1
internal/config/config.go
··· 331 331 }, 332 332 UI: UIConfig{ 333 333 Theme: "dark", 334 - InboxCount: 50, 334 + InboxCount: 200, 335 335 BgSyncInterval: 5, 336 336 Signature: "*sent from [neomd](https://neomd.ssp.sh)*", 337 337 },
+7
internal/config/config_test.go
··· 20 20 {"embedded dollar", "literal$value", "", "", "literal$value"}, 21 21 {"multiple dollars", "pa$$word", "", "", "pa$$word"}, 22 22 {"empty string", "", "", "", ""}, 23 + {"unset var bare", "$UNSET_NEOMD_VAR", "", "", ""}, 24 + {"unset var braced", "${UNSET_NEOMD_VAR}", "", "", ""}, 25 + {"bare $ alone", "$", "", "", ""}, 26 + {"empty braced ${}", "${}", "", "", ""}, 27 + {"whitespace trimmed", " $MY_VAR ", "MY_VAR", "trimmed", "trimmed"}, 28 + {"$VAR with suffix", "$MY_VAR-suffix", "", "", ""}, 29 + {"text before $VAR", "prefix-$MY_VAR", "", "", "prefix-$MY_VAR"}, 23 30 } 24 31 for _, tt := range tests { 25 32 t.Run(tt.name, func(t *testing.T) {
+17 -19
internal/editor/editor.go
··· 86 86 } 87 87 88 88 // Prelude builds the header shown at the top of a new compose buffer. 89 - // cc may be empty. If signature is non-empty it is appended after a blank line separator. 90 - func Prelude(to, cc, subject, signature string) string { 89 + // cc, bcc, and from may be empty. If signature is non-empty it is appended 90 + // after a blank line separator. 91 + func Prelude(to, cc, bcc, from, subject, signature string) string { 91 92 s := fmt.Sprintf("# [neomd: to: %s]\n", to) 92 93 if cc != "" { 93 94 s += fmt.Sprintf("# [neomd: cc: %s]\n", cc) 94 95 } 96 + if bcc != "" { 97 + s += fmt.Sprintf("# [neomd: bcc: %s]\n", bcc) 98 + } 99 + if from != "" { 100 + s += fmt.Sprintf("# [neomd: from: %s]\n", from) 101 + } 95 102 s += fmt.Sprintf("# [neomd: subject: %s]\n\n", subject) 96 103 if signature != "" { 97 104 s += "\n\n-- \n" + signature + "\n" ··· 101 108 102 109 // ReplyPrelude builds a quote block for replies. cc and from may be empty. 103 110 func ReplyPrelude(to, cc, subject, from, originalFrom, originalBody string) string { 104 - s := fmt.Sprintf("# [neomd: to: %s]\n", to) 105 - if cc != "" { 106 - s += fmt.Sprintf("# [neomd: cc: %s]\n", cc) 107 - } 108 - if from != "" { 109 - s += fmt.Sprintf("# [neomd: from: %s]\n", from) 110 - } 111 - s += fmt.Sprintf("# [neomd: subject: %s]\n\n---\n\n> **%s** wrote:\n>\n%s\n\n---\n\n", 112 - subject, originalFrom, quoteLines(originalBody)) 113 - return s 111 + return Prelude(to, cc, "", from, subject, "") + 112 + fmt.Sprintf("---\n\n> **%s** wrote:\n>\n%s\n\n---\n\n", 113 + originalFrom, quoteLines(originalBody)) 114 114 } 115 115 116 116 // ForwardPrelude builds a quoted forward block. The To field is left empty for 117 117 // the user to fill in. 118 118 func ForwardPrelude(subject, from, originalFrom, originalDate, originalTo, originalBody string) string { 119 - s := "# [neomd: to: ]\n" 120 - if from != "" { 121 - s += fmt.Sprintf("# [neomd: from: %s]\n", from) 122 - } 123 119 if !strings.HasPrefix(strings.ToLower(subject), "fwd:") { 124 120 subject = "Fwd: " + subject 125 121 } 126 - s += fmt.Sprintf("# [neomd: subject: %s]\n\n", subject) 122 + s := Prelude("", "", "", from, subject, "") 127 123 s += "---------- Forwarded message ----------\n" 128 124 s += fmt.Sprintf("From: %s\n", originalFrom) 129 125 s += fmt.Sprintf("Date: %s\n", originalDate) ··· 134 130 } 135 131 136 132 // ParseHeaders scans raw editor content for # [neomd: key: value] lines and 137 - // returns the extracted to, cc, bcc, subject values and the remaining body 133 + // returns the extracted to, cc, bcc, from, subject values and the remaining body 138 134 // (with header lines stripped). Any field not found is returned as "". 139 - func ParseHeaders(raw string) (to, cc, bcc, subject, body string) { 135 + func ParseHeaders(raw string) (to, cc, bcc, from, subject, body string) { 140 136 lines := splitLines(raw) 141 137 var kept []string 142 138 for _, line := range lines { ··· 148 144 cc = strings.TrimSpace(m[2]) 149 145 case "bcc": 150 146 bcc = strings.TrimSpace(m[2]) 147 + case "from": 148 + from = strings.TrimSpace(m[2]) 151 149 case "subject": 152 150 subject = strings.TrimSpace(m[2]) 153 151 }
+55 -26
internal/editor/editor_test.go
··· 7 7 8 8 func TestParseHeaders(t *testing.T) { 9 9 tests := []struct { 10 - name string 11 - input string 12 - wantTo, wantCC, wantBCC, wantSub string 13 - wantBodyContains string // substring the body must contain 14 - wantBodyNotContains string // substring the body must NOT contain 10 + name string 11 + input string 12 + wantTo, wantCC, wantBCC, wantFrom, wantSub string 13 + wantBodyContains string // substring the body must contain 14 + wantBodyNotContains string // substring the body must NOT contain 15 15 }{ 16 16 { 17 17 name: "all fields present", 18 18 input: "# [neomd: to: alice@example.com]\n" + 19 19 "# [neomd: cc: bob@example.com]\n" + 20 20 "# [neomd: bcc: secret@example.com]\n" + 21 + "# [neomd: from: Me <me@example.com>]\n" + 21 22 "# [neomd: subject: Hello World]\n" + 22 23 "\n" + 23 24 "Body text here.\n", 24 25 wantTo: "alice@example.com", 25 26 wantCC: "bob@example.com", 26 27 wantBCC: "secret@example.com", 28 + wantFrom: "Me <me@example.com>", 27 29 wantSub: "Hello World", 28 30 wantBodyContains: "Body text here.", 29 31 wantBodyNotContains: "neomd:", ··· 55 57 wantBodyContains: "## Heading", 56 58 }, 57 59 { 58 - name: "no headers at all", 59 - input: "Just plain text\nwith multiple lines.\n", 60 - wantTo: "", 61 - wantCC: "", 62 - wantBCC: "", 63 - wantSub: "", 64 - wantBodyContains: "Just plain text", 60 + name: "no headers at all", 61 + input: "Just plain text\nwith multiple lines.\n", 62 + wantTo: "", 63 + wantCC: "", 64 + wantBCC: "", 65 + wantSub: "", 66 + wantBodyContains: "Just plain text", 65 67 wantBodyNotContains: "", 66 68 }, 67 69 { ··· 77 79 78 80 for _, tt := range tests { 79 81 t.Run(tt.name, func(t *testing.T) { 80 - to, cc, bcc, subject, body := ParseHeaders(tt.input) 82 + to, cc, bcc, from, subject, body := ParseHeaders(tt.input) 81 83 if to != tt.wantTo { 82 84 t.Errorf("to = %q, want %q", to, tt.wantTo) 83 85 } ··· 86 88 } 87 89 if bcc != tt.wantBCC { 88 90 t.Errorf("bcc = %q, want %q", bcc, tt.wantBCC) 91 + } 92 + if from != tt.wantFrom { 93 + t.Errorf("from = %q, want %q", from, tt.wantFrom) 89 94 } 90 95 if subject != tt.wantSub { 91 96 t.Errorf("subject = %q, want %q", subject, tt.wantSub) ··· 102 107 103 108 func TestPrelude(t *testing.T) { 104 109 tests := []struct { 105 - name string 106 - to, cc string 107 - subject string 108 - signature string 109 - wantHas []string // substrings that must appear 110 - wantNot []string // substrings that must NOT appear 110 + name string 111 + to, cc, bcc, from string 112 + subject string 113 + signature string 114 + wantHas []string // substrings that must appear 115 + wantNot []string // substrings that must NOT appear 111 116 }{ 112 117 { 113 118 name: "basic without cc or sig", ··· 117 122 "# [neomd: to: alice@example.com]", 118 123 "# [neomd: subject: Greetings]", 119 124 }, 120 - wantNot: []string{"# [neomd: cc:", "-- \n"}, 125 + wantNot: []string{"# [neomd: cc:", "# [neomd: bcc:", "# [neomd: from:", "-- \n"}, 121 126 }, 122 127 { 123 128 name: "with cc", ··· 131 136 }, 132 137 }, 133 138 { 139 + name: "with bcc and from", 140 + to: "alice@example.com", 141 + bcc: "secret@example.com", 142 + from: "Me <me@example.com>", 143 + subject: "Private", 144 + wantHas: []string{ 145 + "# [neomd: bcc: secret@example.com]", 146 + "# [neomd: from: Me <me@example.com>]", 147 + }, 148 + }, 149 + { 134 150 name: "with signature", 135 151 to: "a@b.com", 136 152 subject: "Sig test", ··· 147 163 148 164 for _, tt := range tests { 149 165 t.Run(tt.name, func(t *testing.T) { 150 - got := Prelude(tt.to, tt.cc, tt.subject, tt.signature) 166 + got := Prelude(tt.to, tt.cc, tt.bcc, tt.from, tt.subject, tt.signature) 151 167 for _, want := range tt.wantHas { 152 168 if !strings.Contains(got, want) { 153 169 t.Errorf("Prelude missing %q, got:\n%s", want, got) ··· 231 247 232 248 func TestPreludeParseHeadersRoundTrip(t *testing.T) { 233 249 tests := []struct { 234 - name string 235 - to, cc string 236 - subject string 250 + name string 251 + to, cc, bcc, from string 252 + subject string 237 253 }{ 238 254 { 239 255 name: "to and subject only", ··· 245 261 to: "alice@example.com", 246 262 cc: "bob@example.com", 247 263 subject: "With CC", 264 + }, 265 + { 266 + name: "with bcc and from", 267 + to: "alice@example.com", 268 + bcc: "secret@example.com", 269 + from: "Me <me@example.com>", 270 + subject: "With hidden recipients", 248 271 }, 249 272 } 250 273 251 274 for _, tt := range tests { 252 275 t.Run(tt.name, func(t *testing.T) { 253 - prelude := Prelude(tt.to, tt.cc, tt.subject, "") 254 - gotTo, gotCC, _, gotSubject, _ := ParseHeaders(prelude) 276 + prelude := Prelude(tt.to, tt.cc, tt.bcc, tt.from, tt.subject, "") 277 + gotTo, gotCC, gotBCC, gotFrom, gotSubject, _ := ParseHeaders(prelude) 255 278 if gotTo != tt.to { 256 279 t.Errorf("round-trip to = %q, want %q", gotTo, tt.to) 257 280 } 258 281 if gotCC != tt.cc { 259 282 t.Errorf("round-trip cc = %q, want %q", gotCC, tt.cc) 283 + } 284 + if gotBCC != tt.bcc { 285 + t.Errorf("round-trip bcc = %q, want %q", gotBCC, tt.bcc) 286 + } 287 + if gotFrom != tt.from { 288 + t.Errorf("round-trip from = %q, want %q", gotFrom, tt.from) 260 289 } 261 290 if gotSubject != tt.subject { 262 291 t.Errorf("round-trip subject = %q, want %q", gotSubject, tt.subject)
+16 -1
internal/imap/client.go
··· 37 37 From string 38 38 To string 39 39 CC string // comma-separated CC addresses (may be empty) 40 + BCC string // comma-separated BCC addresses (mainly useful for Drafts) 40 41 ReplyTo string // Reply-To address if present (may be empty) 41 42 Subject string 42 43 Date time.Time 43 44 Seen bool 44 - Answered bool // \Answered flag — set when replied to from any client 45 + Answered bool // \Answered flag — set when replied to from any client 45 46 Folder string 46 47 Size uint32 // RFC822 size in bytes 47 48 HasAttachment bool // true if BODYSTRUCTURE contains an attachment part ··· 294 295 cc = append(cc, a.Addr()) 295 296 } 296 297 e.CC = strings.Join(cc, ", ") 298 + } 299 + if len(m.Envelope.Bcc) > 0 { 300 + bcc := make([]string, 0, len(m.Envelope.Bcc)) 301 + for _, a := range m.Envelope.Bcc { 302 + bcc = append(bcc, a.Addr()) 303 + } 304 + e.BCC = strings.Join(bcc, ", ") 297 305 } 298 306 if len(m.Envelope.ReplyTo) > 0 { 299 307 e.ReplyTo = m.Envelope.ReplyTo[0].Addr() ··· 670 678 cc = append(cc, a.Addr()) 671 679 } 672 680 e.CC = strings.Join(cc, ", ") 681 + } 682 + if len(m.Envelope.Bcc) > 0 { 683 + bcc := make([]string, 0, len(m.Envelope.Bcc)) 684 + for _, a := range m.Envelope.Bcc { 685 + bcc = append(bcc, a.Addr()) 686 + } 687 + e.BCC = strings.Join(bcc, ", ") 673 688 } 674 689 } 675 690 e.Size = uint32(m.RFC822Size)
+72 -6
internal/screener/screener.go
··· 15 15 type Category int 16 16 17 17 const ( 18 - CategoryToScreen Category = iota // unknown — awaiting decision 19 - CategoryInbox // approved sender 20 - CategoryScreenedOut // blocked (known human/company) 21 - CategoryFeed // newsletter / feed 22 - CategoryPaperTrail // receipts / notifications 23 - CategorySpam // actual spam — never needs review 18 + CategoryToScreen Category = iota // unknown — awaiting decision 19 + CategoryInbox // approved sender 20 + CategoryScreenedOut // blocked (known human/company) 21 + CategoryFeed // newsletter / feed 22 + CategoryPaperTrail // receipts / notifications 23 + CategorySpam // actual spam — never needs review 24 24 ) 25 25 26 26 func (c Category) String() string { ··· 57 57 feed map[string]bool 58 58 paperTrail map[string]bool 59 59 spam map[string]bool 60 + } 61 + 62 + // Snapshot is a point-in-time copy of all screener list files and in-memory sets. 63 + // It is used to roll back a failed screener operation. 64 + type Snapshot struct { 65 + ScreenedIn map[string]bool 66 + ScreenedOut map[string]bool 67 + Feed map[string]bool 68 + PaperTrail map[string]bool 69 + Spam map[string]bool 60 70 } 61 71 62 72 // New loads all lists from the paths in cfg. ··· 164 174 // MarkPaperTrail adds addr to papertrail.txt and updates the in-memory set. 165 175 func (s *Screener) MarkPaperTrail(from string) error { 166 176 return s.addToList(s.cfg.PaperTrail, s.paperTrail, from) 177 + } 178 + 179 + func cloneSet(src map[string]bool) map[string]bool { 180 + dst := make(map[string]bool, len(src)) 181 + for k, v := range src { 182 + dst[k] = v 183 + } 184 + return dst 185 + } 186 + 187 + // Snapshot captures the current screener state so a caller can roll back. 188 + func (s *Screener) Snapshot() Snapshot { 189 + return Snapshot{ 190 + ScreenedIn: cloneSet(s.screenedIn), 191 + ScreenedOut: cloneSet(s.screenedOut), 192 + Feed: cloneSet(s.feed), 193 + PaperTrail: cloneSet(s.paperTrail), 194 + Spam: cloneSet(s.spam), 195 + } 196 + } 197 + 198 + func writeSet(path string, m map[string]bool) error { 199 + lines := make([]string, 0, len(m)) 200 + for addr := range m { 201 + lines = append(lines, addr) 202 + } 203 + content := "" 204 + if len(lines) > 0 { 205 + content = strings.Join(lines, "\n") + "\n" 206 + } 207 + return os.WriteFile(path, []byte(content), 0600) 208 + } 209 + 210 + // Restore rewrites all screener list files and in-memory sets from a snapshot. 211 + func (s *Screener) Restore(snapshot Snapshot) error { 212 + if err := writeSet(s.cfg.ScreenedIn, snapshot.ScreenedIn); err != nil { 213 + return err 214 + } 215 + if err := writeSet(s.cfg.ScreenedOut, snapshot.ScreenedOut); err != nil { 216 + return err 217 + } 218 + if err := writeSet(s.cfg.Feed, snapshot.Feed); err != nil { 219 + return err 220 + } 221 + if err := writeSet(s.cfg.PaperTrail, snapshot.PaperTrail); err != nil { 222 + return err 223 + } 224 + if err := writeSet(s.cfg.Spam, snapshot.Spam); err != nil { 225 + return err 226 + } 227 + s.screenedIn = cloneSet(snapshot.ScreenedIn) 228 + s.screenedOut = cloneSet(snapshot.ScreenedOut) 229 + s.feed = cloneSet(snapshot.Feed) 230 + s.paperTrail = cloneSet(snapshot.PaperTrail) 231 + s.spam = cloneSet(snapshot.Spam) 232 + return nil 167 233 } 168 234 169 235 // removeFromList deletes addr from the file and in-memory set if present.
+33
internal/screener/screener_test.go
··· 342 342 } 343 343 } 344 344 }) 345 + 346 + t.Run("Snapshot and Restore roll back mutations", func(t *testing.T) { 347 + dir := t.TempDir() 348 + cfg := makeCfg(dir) 349 + 350 + s, err := New(cfg) 351 + if err != nil { 352 + t.Fatal(err) 353 + } 354 + if err := s.Approve("undo@example.com"); err != nil { 355 + t.Fatal(err) 356 + } 357 + snap := s.Snapshot() 358 + if err := s.Block("undo@example.com"); err != nil { 359 + t.Fatal(err) 360 + } 361 + if got := s.Classify("undo@example.com"); got != CategoryScreenedOut { 362 + t.Fatalf("after Block got %v, want ScreenedOut", got) 363 + } 364 + if err := s.Restore(snap); err != nil { 365 + t.Fatal(err) 366 + } 367 + if got := s.Classify("undo@example.com"); got != CategoryInbox { 368 + t.Fatalf("after Restore got %v, want Inbox", got) 369 + } 370 + data, err := os.ReadFile(cfg.ScreenedIn) 371 + if err != nil { 372 + t.Fatal(err) 373 + } 374 + if string(data) != "undo@example.com\n" { 375 + t.Fatalf("screened_in contents = %q, want restored entry", data) 376 + } 377 + }) 345 378 } 346 379 347 380 // ---------------------------------------------------------------------------
+18
internal/smtp/sender.go
··· 199 199 return buildMessage(from, to, cc, subject, markdownBody, htmlBody, attachments) 200 200 } 201 201 202 + // BuildDraftMessage constructs a raw MIME draft for IMAP APPEND. 203 + // Unlike SMTP delivery, drafts should retain the Bcc header so the user's 204 + // intent survives round-tripping through Drafts. 205 + func BuildDraftMessage(from, to, cc, bcc, subject, markdownBody string, attachments []string) ([]byte, error) { 206 + htmlBody, err := render.ToHTML(markdownBody) 207 + if err != nil { 208 + return nil, fmt.Errorf("markdown to html: %w", err) 209 + } 210 + return buildMessageWithBCC(from, to, cc, bcc, subject, markdownBody, htmlBody, attachments) 211 + } 212 + 202 213 // inlineImage holds a local image path and its assigned Content-ID. 203 214 type inlineImage struct { 204 215 path string ··· 215 226 // - images only → multipart/related > (multipart/alternative + inline images) 216 227 // - images + files → multipart/mixed > (multipart/related > alt+images) + files 217 228 func buildMessage(from, to, cc, subject, plainText, htmlBody string, attachments []string) ([]byte, error) { 229 + return buildMessageWithBCC(from, to, cc, "", subject, plainText, htmlBody, attachments) 230 + } 231 + 232 + func buildMessageWithBCC(from, to, cc, bcc, subject, plainText, htmlBody string, attachments []string) ([]byte, error) { 218 233 // Find local image paths in htmlBody (<img src="/abs/path">), assign CIDs. 219 234 var inlines []inlineImage 220 235 processedHTML := imgSrcRe.ReplaceAllStringFunc(htmlBody, func(match string) string { ··· 245 260 hdr("To", to) 246 261 if cc != "" { 247 262 hdr("Cc", cc) 263 + } 264 + if bcc != "" { 265 + hdr("Bcc", bcc) 248 266 } 249 267 hdr("Subject", mime.QEncoding.Encode("utf-8", subject)) 250 268 hdr("Date", time.Now().Format(time.RFC1123Z))
+16 -3
internal/ui/cmdline.go
··· 13 13 14 14 // neomdCmd is a registered colon-command (like vim's :command). 15 15 type neomdCmd struct { 16 - name string // full name, e.g. "screen-all" 16 + name string // full name, e.g. "screen-all" 17 17 aliases []string // short forms accepted, e.g. ["sa", "screen-a"] 18 18 desc string 19 19 // run is called when the command is executed; m is the current model. ··· 32 32 aliases: []string{"s"}, 33 33 desc: "screen currently loaded emails only (up to inbox_count)", 34 34 run: func(m *Model) (tea.Model, tea.Cmd) { 35 + if err := m.validateScreenerSafety(); err != nil { 36 + m.status = err.Error() 37 + m.isError = true 38 + return m, nil 39 + } 35 40 moves := m.previewAutoScreen() 36 41 if len(moves) == 0 { 37 42 m.status = "Nothing to screen — all senders already classified." ··· 47 52 aliases: []string{"sa", "screen-a"}, 48 53 desc: "fetch and screen EVERY inbox email, no limit (use after updating screener lists)", 49 54 run: func(m *Model) (tea.Model, tea.Cmd) { 55 + if err := m.validateScreenerSafety(); err != nil { 56 + m.status = err.Error() 57 + m.isError = true 58 + return m, nil 59 + } 50 60 m.loading = true 51 61 return m, m.deepScreenCmd() 52 62 }, ··· 137 147 { 138 148 name: "search", 139 149 aliases: []string{"se"}, 140 - desc: "IMAP search all emails in current folder (From + Subject)", 150 + desc: "IMAP search all emails across all configured folders (From + Subject + To)", 141 151 run: func(m *Model) (tea.Model, tea.Cmd) { 142 152 m.imapSearchActive = true 143 153 m.imapSearchText = "" ··· 182 192 m.isError = true 183 193 return m, nil 184 194 } 185 - to, cc, bcc, subject, body := editor.ParseHeaders(string(raw)) 195 + to, cc, bcc, from, subject, body := editor.ParseHeaders(string(raw)) 186 196 187 197 // Pre-fill compose fields. 188 198 m.compose.reset() 189 199 m.presendFromI = 0 200 + if idx := m.matchFromAddress(from); idx >= 0 { 201 + m.presendFromI = idx 202 + } 190 203 m.compose.to.SetValue(to) 191 204 m.compose.cc.SetValue(cc) 192 205 m.compose.bcc.SetValue(bcc)
+13 -3
internal/ui/inbox.go
··· 17 17 email imap.Email 18 18 index int // position in list (1-based) 19 19 marked bool // selected for batch operation 20 + displaySubj string // rendered subject (may include folder prefix in temporary views) 20 21 threadPrefix string // tree chars e.g. "┌─>" for threaded display 21 22 } 22 23 ··· 33 34 draftFolder string // when active folder matches, show To instead of From 34 35 } 35 36 36 - func (d emailDelegate) Height() int { return 1 } 37 + func (d emailDelegate) Height() int { return 1 } 37 38 func (d emailDelegate) Spacing() int { return 0 } 38 39 func (d emailDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } 39 40 ··· 105 106 sender = "→ " + e.email.To // show recipient in Drafts 106 107 } 107 108 from := truncate(cleanFrom(sender), fromMax) 108 - subject := truncate(e.email.Subject, subjectMax) 109 + subjectText := e.email.Subject 110 + if e.displaySubj != "" { 111 + subjectText = e.displaySubj 112 + } 113 + subject := truncate(subjectText, subjectMax) 109 114 110 115 if isSelected { 111 116 row := fmt.Sprintf("%s%s%s%s%s%s%-*s %-*s %s", ··· 232 237 // setEmails replaces the list contents, preserving marked state. 233 238 // It threads emails before display — grouped conversations appear together 234 239 // with tree-drawing prefixes (┌─>) on reply rows. 235 - func setEmails(l *list.Model, emails []imap.Email, marked map[uint32]bool) tea.Cmd { 240 + func setEmails(l *list.Model, emails []imap.Email, marked map[uint32]bool, prefixFolders bool) tea.Cmd { 236 241 threaded := threadEmails(emails) 237 242 items := make([]list.Item, len(threaded)) 238 243 for i, te := range threaded { 244 + displaySubj := te.email.Subject 245 + if prefixFolders { 246 + displaySubj = "[" + te.email.Folder + "] " + displaySubj 247 + } 239 248 items[i] = emailItem{ 240 249 email: te.email, 241 250 index: i + 1, 242 251 marked: marked[te.email.UID], 252 + displaySubj: displaySubj, 243 253 threadPrefix: te.threadPrefix, 244 254 } 245 255 }
+3
internal/ui/keys.go
··· 12 12 var HelpSections = []HelpSection{ 13 13 {"Navigation", [][2]string{ 14 14 {"j / k", "move down / up"}, 15 + {"d / u", "page down / up in inbox/help"}, 15 16 {"gg", "jump to top"}, 16 17 {"G", "jump to bottom"}, 17 18 {"enter / l", "open email"}, ··· 30 31 {"gk", "go to ToScreen"}, 31 32 {"go", "go to ScreenedOut"}, 32 33 {"gw", "go to Waiting"}, 34 + {"gb", "go to Work (if configured)"}, 33 35 {"gm", "go to Someday"}, 34 36 {"gd", "go to Drafts"}, 35 37 {"ge", "go to Everything — latest 50 emails across all folders"}, ··· 53 55 {"Mt", "move to Trash"}, 54 56 {"Mo", "move to ScreenedOut"}, 55 57 {"Mw", "move to Waiting"}, 58 + {"Mb", "move to Work (if configured)"}, 56 59 {"Mm", "move to Someday"}, 57 60 {"Mk", "move to ToScreen"}, 58 61 }},
+609 -96
internal/ui/model.go
··· 8 8 "path/filepath" 9 9 "regexp" 10 10 "sort" 11 + "strconv" 11 12 "strings" 12 13 "sync/atomic" 13 14 "time" ··· 122 123 err error 123 124 } 124 125 editorDoneMsg struct { 125 - to, cc, bcc, subject, body string 126 - err error 127 - aborted bool // true when file was unchanged (ZQ / :q!) 126 + to, cc, bcc, from, subject, body string 127 + err error 128 + aborted bool // true when file was unchanged (ZQ / :q!) 128 129 } 129 130 ) 130 131 ··· 169 170 return dir 170 171 } 171 172 173 + func detectStartupNotice() string { 174 + _, hasYazi := exec.LookPath("yazi") 175 + home, _ := os.UserHomeDir() 176 + customLua := filepath.Join(home, ".config", "nvim", "lua", "sspaeti", "custom.lua") 177 + _, customLuaErr := os.Stat(customLua) 178 + 179 + switch { 180 + case hasYazi != nil && customLuaErr != nil: 181 + return "Optional inline <leader>a attachments in nvim are unavailable: install yazi and add the custom.lua integration. Pre-send 'a' still works." 182 + case hasYazi != nil: 183 + return "Optional inline <leader>a attachments in nvim are unavailable: install yazi. Pre-send 'a' still works." 184 + case customLuaErr != nil: 185 + return "Optional inline <leader>a attachments in nvim are not configured. Add the custom.lua integration if you want that workflow; pre-send 'a' still works." 186 + default: 187 + return "" 188 + } 189 + } 190 + 172 191 // backupFile holds a backup's full path and modification time. 173 192 type backupFile struct { 174 193 path string ··· 373 392 to, cc, bcc, subject, body string 374 393 // replyToUID/replyToFolder track the original email when this is a reply, 375 394 // so we can set \Answered after sending. Zero means not a reply. 376 - replyToUID uint32 377 - replyToFolder string 395 + replyToUID uint32 396 + replyToFolder string 397 + replyToAccount string 378 398 } 379 399 380 400 // undoMove records one IMAP move so it can be reversed with u. ··· 433 453 presendFromI int // index into presendFroms() for the From field cycle 434 454 435 455 // Status / error 436 - status string 437 - isError bool 456 + status string 457 + isError bool 458 + startupNotice string 438 459 439 460 // Auto-screen dry-run: populated by S, cleared by y/n 440 461 pendingMoves []autoScreenMove ··· 457 478 // prevState is the state to return to when closing the help overlay 458 479 prevState viewState 459 480 460 - // helpSearch is the live filter string typed in the help overlay 461 - helpSearch string 481 + // helpSearch / helpScroll track the ? overlay state. 482 + helpSearch string 483 + helpSearchActive bool 484 + helpScroll int 462 485 463 486 // cmdMode / cmdText / cmdTabI implement vim-style ":" command line. 464 487 cmdMode bool ··· 484 507 485 508 // pendingDeleteAll holds UIDs + folder awaiting y/n before permanent deletion. 486 509 pendingDeleteAll *deleteAllReadyMsg 510 + 511 + // pendingDiscard asks for y/n confirmation before dropping unsent compose state. 512 + pendingDiscard bool 487 513 488 514 // folderCounts holds unseen message counts for watched folder tabs. 489 515 // Keys are tab labels: "Inbox", "PaperTrail", "Waiting", "Scheduled". ··· 515 541 cmdHistory: loadCmdHistory(config.HistoryPath()), 516 542 cmdHistI: -1, 517 543 // Note: Spam is intentionally excluded from tabs — use :go-spam to visit. 518 - compose: compose, 519 - spinner: sp, 520 - markedUIDs: make(map[uint32]bool), 521 - sortField: "date", 522 - sortReverse: true, // newest first 544 + compose: compose, 545 + spinner: sp, 546 + markedUIDs: make(map[uint32]bool), 547 + startupNotice: detectStartupNotice(), 548 + sortField: "date", 549 + sortReverse: true, // newest first 523 550 } 524 551 } 525 552 ··· 588 615 return m.activeAccount() 589 616 } 590 617 618 + func (m Model) imapCliForAccount(accountName string) *imap.Client { 619 + for i, a := range m.accounts { 620 + if strings.EqualFold(a.Name, accountName) && i < len(m.clients) { 621 + return m.clients[i] 622 + } 623 + } 624 + return m.imapCli() 625 + } 626 + 627 + func (m Model) presendIMAPClient() *imap.Client { 628 + return m.imapCliForAccount(m.presendSMTPAccount().Name) 629 + } 630 + 631 + func (m *Model) applyEditedFrom(from string) { 632 + if idx := m.matchFromAddress(from); idx >= 0 { 633 + m.presendFromI = idx 634 + } 635 + } 636 + 591 637 // imapCli returns the IMAP client for the active account. 592 638 func (m Model) imapCli() *imap.Client { 593 639 if m.accountI < len(m.clients) { ··· 610 656 611 657 // activeFolder maps the active tab label to an IMAP mailbox name. 612 658 func (m Model) activeFolder() string { 659 + switch m.offTabFolder { 660 + case "Drafts": 661 + return m.cfg.Folders.Drafts 662 + case "Spam": 663 + return m.cfg.Folders.Spam 664 + } 613 665 switch m.folders[m.activeFolderI] { 614 666 case "ToScreen": 615 667 return m.cfg.Folders.ToScreen ··· 662 714 } 663 715 } 664 716 665 - func (m Model) sendEmailCmd(smtpAcct config.AccountConfig, from, to, cc, bcc, subject, body string, attachments []string, replyToUID uint32, replyToFolder string) tea.Cmd { 717 + func (m Model) sendEmailCmd(smtpAcct config.AccountConfig, from, to, cc, bcc, subject, body string, attachments []string, replyToUID uint32, replyToFolder, replyToAccount string) tea.Cmd { 666 718 h, p := splitAddr(smtpAcct.SMTP) 667 719 cfg := smtp.Config{ 668 720 Host: h, ··· 673 725 STARTTLS: smtpAcct.STARTTLS, 674 726 TokenSource: m.tokenSourceFor(smtpAcct.Name), 675 727 } 676 - cli := m.imapCli() 728 + cli := m.imapCliForAccount(smtpAcct.Name) 677 729 sentFolder := m.cfg.Folders.Sent 730 + replyCli := m.imapCliForAccount(replyToAccount) 678 731 return func() tea.Msg { 679 732 // Build raw MIME once — reused for both SMTP delivery and Sent copy. 680 733 // BCC is intentionally excluded from headers but included in RCPT TO. ··· 692 745 } 693 746 // Mark original email as \Answered (non-fatal). 694 747 if replyToUID > 0 && replyToFolder != "" { 695 - _ = cli.MarkAnswered(nil, replyToFolder, replyToUID) 748 + _ = replyCli.MarkAnswered(nil, replyToFolder, replyToUID) 696 749 } 697 750 return sendDoneMsg{replyToUID: replyToUID, replyToFolder: replyToFolder} 698 751 } ··· 756 809 return nil 757 810 } 758 811 812 + func normalizedSender(from string) string { 813 + return strings.ToLower(extractEmailAddr(from)) 814 + } 815 + 816 + func writeAttachmentsTemp(files []imap.Attachment) ([]string, error) { 817 + paths := make([]string, 0, len(files)) 818 + for _, a := range files { 819 + base := filepath.Base(a.Filename) 820 + if base == "." || base == string(filepath.Separator) || base == "" { 821 + base = "attachment" 822 + } 823 + f, err := os.CreateTemp(neomdTempDir(), "draft-"+base+"-*") 824 + if err != nil { 825 + return nil, err 826 + } 827 + if _, err := f.Write(a.Data); err != nil { 828 + f.Close() 829 + os.Remove(f.Name()) 830 + return nil, err 831 + } 832 + if err := f.Close(); err != nil { 833 + os.Remove(f.Name()) 834 + return nil, err 835 + } 836 + paths = append(paths, f.Name()) 837 + } 838 + return paths, nil 839 + } 840 + 841 + func (m Model) validateScreenerSafety() error { 842 + dests := map[string]string{ 843 + "ToScreen": m.cfg.Folders.ToScreen, 844 + "ScreenedOut": m.cfg.Folders.ScreenedOut, 845 + "Feed": m.cfg.Folders.Feed, 846 + "PaperTrail": m.cfg.Folders.PaperTrail, 847 + "Spam": m.cfg.Folders.Spam, 848 + } 849 + for name, folder := range dests { 850 + if folder != "" && folder == m.cfg.Folders.Trash { 851 + return fmt.Errorf("unsafe folder config: %s points to Trash (%s); refusing to screen until config is fixed", name, folder) 852 + } 853 + } 854 + return nil 855 + } 856 + 857 + func (m Model) inboxPageStep() int { 858 + if m.height <= 8 { 859 + return 10 860 + } 861 + return m.height - 6 862 + } 863 + 864 + func (m Model) hasComposeDraft() bool { 865 + if m.pendingSend != nil { 866 + if strings.TrimSpace(m.pendingSend.to) != "" || 867 + strings.TrimSpace(m.pendingSend.cc) != "" || 868 + strings.TrimSpace(m.pendingSend.bcc) != "" || 869 + strings.TrimSpace(m.pendingSend.subject) != "" || 870 + strings.TrimSpace(m.pendingSend.body) != "" { 871 + return true 872 + } 873 + } 874 + if strings.TrimSpace(m.compose.to.Value()) != "" || 875 + strings.TrimSpace(m.compose.cc.Value()) != "" || 876 + strings.TrimSpace(m.compose.bcc.Value()) != "" || 877 + strings.TrimSpace(m.compose.subject.Value()) != "" { 878 + return true 879 + } 880 + return len(m.attachments) > 0 881 + } 882 + 883 + func (m *Model) beginDiscardConfirm() { 884 + m.pendingDiscard = true 885 + m.status = "Discard unsent message? · y discard, n keep editing" 886 + m.isError = true 887 + } 888 + 889 + func (m *Model) cancelDiscardConfirm() { 890 + m.pendingDiscard = false 891 + if m.status == "Discard unsent message? · y discard, n keep editing" { 892 + m.status = "" 893 + m.isError = false 894 + } 895 + } 896 + 759 897 // batchMoveCmd moves a slice of emails to dst, emitting batchDoneMsg. 760 898 func (m Model) batchMoveCmd(emails []imap.Email, dst string) tea.Cmd { 761 899 type mv struct { ··· 825 963 } 826 964 bp := m.bulkProgress 827 965 return func() tea.Msg { 828 - for i, o := range ops { 829 - // Move first, classify after — if move fails, screener file stays unchanged. 830 - if o.dst != "" && o.dst != o.srcFolder { 831 - if _, err := m.imapCli().MoveMessage(nil, o.srcFolder, o.uid, o.dst); err != nil { 832 - return batchDoneMsg{err: fmt.Errorf("stopped after %d/%d: %w", i, len(ops), err)} 966 + if err := m.validateScreenerSafety(); err != nil { 967 + return batchDoneMsg{err: err} 968 + } 969 + expandedOps := ops 970 + if len(emails) == 1 && len(m.markedUIDs) == 0 && emails[0].Folder == cfg.Folders.ToScreen { 971 + sender := normalizedSender(emails[0].From) 972 + uids, err := m.imapCli().SearchUIDs(nil, cfg.Folders.ToScreen) 973 + if err != nil { 974 + return batchDoneMsg{err: err} 975 + } 976 + expandedOps = nil 977 + for start := 0; start < len(uids); start += 200 { 978 + end := start + 200 979 + if end > len(uids) { 980 + end = len(uids) 981 + } 982 + batch, err := m.imapCli().FetchHeadersByUID(nil, cfg.Folders.ToScreen, uids[start:end]) 983 + if err != nil { 984 + return batchDoneMsg{err: err} 985 + } 986 + for _, e := range batch { 987 + if normalizedSender(e.From) == sender { 988 + var dst string 989 + switch action { 990 + case "I": 991 + dst = cfg.Folders.Inbox 992 + case "O": 993 + dst = cfg.Folders.ScreenedOut 994 + case "F": 995 + dst = cfg.Folders.Feed 996 + case "P": 997 + dst = cfg.Folders.PaperTrail 998 + case "$": 999 + dst = cfg.Folders.Spam 1000 + } 1001 + expandedOps = append(expandedOps, op{e.From, e.Folder, e.UID, dst}) 1002 + } 833 1003 } 834 1004 } 1005 + } 1006 + snapshot := sc.Snapshot() 1007 + seenSenders := make(map[string]bool) 1008 + for _, o := range expandedOps { 1009 + sender := normalizedSender(o.from) 1010 + if seenSenders[sender] { 1011 + continue 1012 + } 1013 + seenSenders[sender] = true 835 1014 var err error 836 1015 switch action { 837 1016 case "I": ··· 846 1025 err = sc.MarkSpam(o.from) 847 1026 } 848 1027 if err != nil { 849 - return batchDoneMsg{err: fmt.Errorf("stopped after %d/%d: %w", i, len(ops), err)} 1028 + _ = sc.Restore(snapshot) 1029 + return batchDoneMsg{err: err} 1030 + } 1031 + } 1032 + var undos []undoMove 1033 + for i, o := range expandedOps { 1034 + if o.dst != "" && o.dst != o.srcFolder { 1035 + destUID, err := m.imapCli().MoveMessage(nil, o.srcFolder, o.uid, o.dst) 1036 + if err != nil { 1037 + var rollbackErrs []string 1038 + for j := len(undos) - 1; j >= 0; j-- { 1039 + u := undos[j] 1040 + if _, undoErr := m.imapCli().MoveMessage(nil, u.toFolder, u.uid, u.fromFolder); undoErr != nil { 1041 + rollbackErrs = append(rollbackErrs, fmt.Sprintf("%s:%d→%s (%v)", u.toFolder, u.uid, u.fromFolder, undoErr)) 1042 + } 1043 + } 1044 + if restoreErr := sc.Restore(snapshot); restoreErr != nil { 1045 + rollbackErrs = append(rollbackErrs, "screener restore: "+restoreErr.Error()) 1046 + } 1047 + if len(rollbackErrs) > 0 { 1048 + return batchDoneMsg{err: fmt.Errorf("stopped after %d/%d: %w (rollback failed: %s)", i, len(expandedOps), err, strings.Join(rollbackErrs, "; "))} 1049 + } 1050 + return batchDoneMsg{err: fmt.Errorf("stopped after %d/%d: %w", i, len(expandedOps), err)} 1051 + } 1052 + undos = append(undos, undoMove{uid: destUID, fromFolder: o.srcFolder, toFolder: o.dst}) 850 1053 } 851 1054 if bp != nil { 852 1055 bp.moved.Add(1) ··· 912 1115 // lookups) and returns planned moves. emails must live at least as long as the 913 1116 // returned moves (pointers into the slice are stored). 914 1117 func (m Model) classifyForScreen(emails []imap.Email) []autoScreenMove { 1118 + if m.validateScreenerSafety() != nil { 1119 + return nil 1120 + } 915 1121 inboxFolder := m.cfg.Folders.Inbox 916 1122 var moves []autoScreenMove 917 1123 for i := range emails { ··· 1179 1385 } 1180 1386 m.activeFolderI = z.folderIndex 1181 1387 m.offTabFolder = "" 1388 + m.imapSearchText = "" 1182 1389 m.loading = true 1183 1390 return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) 1184 1391 } ··· 1212 1419 m.markedUIDs = make(map[uint32]bool) // clear marks on folder reload 1213 1420 m.filterActive = false 1214 1421 m.filterText = "" 1422 + if m.status == "" && m.startupNotice != "" { 1423 + m.status = m.startupNotice 1424 + m.startupNotice = "" 1425 + } 1215 1426 sortCmd := m.sortEmails() // applies sort and sets list items 1216 1427 1217 1428 // First-run welcome: show a brief intro popup. ··· 1228 1439 // Skip when all screener lists are empty — otherwise every email would 1229 1440 // be moved to ToScreen on first run, confusing new users. 1230 1441 if msg.folder == m.cfg.Folders.Inbox && m.cfg.UI.AutoScreen() && !m.screener.IsEmpty() { 1442 + if err := m.validateScreenerSafety(); err != nil { 1443 + m.status = err.Error() 1444 + m.isError = true 1445 + return m, tea.Batch(sortCmd, m.fetchFolderCountsCmd()) 1446 + } 1231 1447 if moves := m.previewAutoScreen(); len(moves) > 0 { 1232 1448 m.loading = true 1233 1449 m.bulkProgress = m.newBulkOp("Screening", len(moves)) ··· 1431 1647 return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) 1432 1648 1433 1649 case deepScreenCountMsg: 1650 + if err := m.validateScreenerSafety(); err != nil { 1651 + m.loading = false 1652 + m.status = err.Error() 1653 + m.isError = true 1654 + return m, nil 1655 + } 1434 1656 // Phase 1 done: we know how many emails exist. Show count and kick off phase 2. 1435 1657 m.status = fmt.Sprintf("Screen-all: found %d emails — fetching headers in batches…", msg.total) 1436 1658 return m, tea.Batch(m.spinner.Tick, m.deepScreenClassifyCmd(nil, msg.uids, msg.total)) ··· 1531 1753 return m, tea.Batch(m.bgFetchInboxCmd(), m.scheduleBgSync()) 1532 1754 1533 1755 case bgInboxFetchedMsg: 1756 + if err := m.validateScreenerSafety(); err != nil { 1757 + m.status = err.Error() 1758 + m.isError = true 1759 + return m, nil 1760 + } 1534 1761 moves := m.classifyForScreen(msg.emails) 1535 1762 if len(moves) == 0 { 1536 1763 return m, nil ··· 1557 1784 1558 1785 case editorDoneMsg: 1559 1786 if msg.err != nil { 1787 + m.attachments = nil 1560 1788 m.status = msg.err.Error() 1561 1789 m.isError = true 1562 1790 m.state = stateInbox 1563 1791 return m, nil 1564 1792 } 1565 1793 if msg.aborted { 1566 - m.status = "Aborted (no changes saved)." 1794 + m.attachments = nil 1795 + m.status = "Aborted (no changes saved). Use :recover to reopen the latest backup." 1567 1796 m.state = stateInbox 1568 1797 return m, nil 1569 1798 } 1570 1799 if strings.TrimSpace(msg.body) == "" { 1571 - m.status = "Cancelled (empty body)." 1800 + m.attachments = nil 1801 + m.status = "Cancelled (empty body). Use :recover if you want the latest backup." 1572 1802 m.state = stateInbox 1573 1803 return m, nil 1574 1804 } 1575 1805 // Strip editor header hints and extract [attach] lines. 1576 1806 inlineAttach, cleanBody := extractInlineAttachments(stripPrelude(msg.body)) 1577 1807 m.attachments = append(m.attachments, inlineAttach...) 1808 + m.applyEditedFrom(msg.from) 1578 1809 1579 1810 // Go to pre-send review instead of sending immediately. 1580 1811 m.pendingSend = &pendingSendData{ ··· 1585 1816 if m.openEmail != nil && strings.HasPrefix(strings.ToLower(msg.subject), "re:") { 1586 1817 m.pendingSend.replyToUID = m.openEmail.UID 1587 1818 m.pendingSend.replyToFolder = m.openEmail.Folder 1819 + m.pendingSend.replyToAccount = m.activeAccount().Name 1588 1820 } 1589 1821 m.state = statePresend 1822 + m.status = "" 1823 + m.isError = false 1590 1824 return m, nil 1591 1825 1592 1826 case attachPickDoneMsg: ··· 1603 1837 m.state = m.prevState 1604 1838 } else { 1605 1839 m.prevState = m.state 1840 + m.helpSearch = "" 1841 + m.helpSearchActive = false 1842 + m.helpScroll = 0 1606 1843 m.state = stateHelp 1607 1844 } 1608 1845 return m, nil ··· 1765 2002 return m, tea.Quit 1766 2003 1767 2004 case "esc": 2005 + if m.filterText != "" { 2006 + m.filterActive = false 2007 + m.filterText = "" 2008 + return m, m.applyFilter() 2009 + } 1768 2010 if m.imapSearchResults { 1769 2011 m.imapSearchResults = false 1770 2012 m.imapSearchText = "" ··· 1772 2014 m.loading = true 1773 2015 return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) 1774 2016 } 2017 + if m.offTabFolder != "" { 2018 + m.offTabFolder = "" 2019 + // If we have a pending search query, restore search results instead of activeFolder 2020 + if m.imapSearchText != "" { 2021 + m.loading = true 2022 + return m, tea.Batch(m.spinner.Tick, m.imapSearchAllCmd(m.imapSearchText)) 2023 + } 2024 + m.loading = true 2025 + return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) 2026 + } 1775 2027 1776 2028 // ── Chord prefixes ────────────────────────────────────────────── 1777 2029 case "g": ··· 1866 2118 if m.folders[m.activeFolderI] != "Inbox" { 1867 2119 break 1868 2120 } 2121 + if err := m.validateScreenerSafety(); err != nil { 2122 + m.status = err.Error() 2123 + m.isError = true 2124 + return m, nil 2125 + } 1869 2126 moves := m.previewAutoScreen() 1870 2127 if len(moves) == 0 { 1871 2128 m.status = "Nothing to screen — all senders already classified." ··· 1936 2193 m.activeFolderI = (m.activeFolderI + 1) % len(m.folders) 1937 2194 m.offTabFolder = "" 1938 2195 m.imapSearchResults = false 2196 + m.imapSearchText = "" 1939 2197 m.loading = true 1940 2198 return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) 1941 2199 ··· 1943 2201 m.activeFolderI = (m.activeFolderI - 1 + len(m.folders)) % len(m.folders) 1944 2202 m.offTabFolder = "" 1945 2203 m.imapSearchResults = false 2204 + m.imapSearchText = "" 1946 2205 m.loading = true 1947 2206 return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) 1948 2207 ··· 1950 2209 m.inbox.Select(len(m.inbox.Items()) - 1) 1951 2210 return m, nil 1952 2211 2212 + case "d": 2213 + next := m.inbox.Index() + m.inboxPageStep() 2214 + if max := len(m.inbox.Items()) - 1; next > max { 2215 + next = max 2216 + } 2217 + if next >= 0 { 2218 + m.inbox.Select(next) 2219 + } 2220 + return m, nil 2221 + 2222 + case "u": 2223 + prev := m.inbox.Index() - m.inboxPageStep() 2224 + if prev < 0 { 2225 + prev = 0 2226 + } 2227 + m.inbox.Select(prev) 2228 + return m, nil 2229 + 1953 2230 case "/": 1954 2231 m.filterActive = true 1955 2232 m.filterText = "" ··· 1973 2250 } 1974 2251 1975 2252 case "c": 2253 + m.attachments = nil 1976 2254 m.state = stateCompose 2255 + m.status = "" 2256 + m.isError = false 1977 2257 m.compose.reset() 1978 2258 m.presendFromI = 0 1979 2259 return m, nil ··· 2105 2385 _ = os.WriteFile(path, []byte(content), 0600) 2106 2386 } 2107 2387 2388 + func (m Model) shouldPrefixFolderInSubject() bool { 2389 + switch m.offTabFolder { 2390 + case "Search", "Everything", "Thread": 2391 + return true 2392 + default: 2393 + return false 2394 + } 2395 + } 2396 + 2108 2397 // addCmdHistory prepends input to history (deduplicating) and caps at 5 entries. 2109 2398 func addCmdHistory(history []string, input string) []string { 2110 2399 // Remove existing occurrence of the same command (dedup) ··· 2128 2417 // Call this whenever filterText changes. 2129 2418 func (m *Model) applyFilter() tea.Cmd { 2130 2419 if m.filterText == "" { 2131 - return setEmails(&m.inbox, m.emails, m.markedUIDs) 2420 + return setEmails(&m.inbox, m.emails, m.markedUIDs, m.shouldPrefixFolderInSubject()) 2132 2421 } 2133 2422 query := strings.ToLower(m.filterText) 2134 2423 var filtered []imap.Email ··· 2138 2427 filtered = append(filtered, e) 2139 2428 } 2140 2429 } 2141 - return setEmails(&m.inbox, filtered, m.markedUIDs) 2430 + return setEmails(&m.inbox, filtered, m.markedUIDs, m.shouldPrefixFolderInSubject()) 2142 2431 } 2143 2432 2144 2433 // handleChord dispatches two-key sequences (g<x>, M<x>, space<x>). ··· 2176 2465 if key == "S" { // gS — go to Spam (not in tab rotation) 2177 2466 m.loading = true 2178 2467 m.offTabFolder = "Spam" 2468 + m.imapSearchText = "" 2179 2469 m.status = "Spam folder — press R to reload, tab to leave" 2180 2470 return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.cfg.Folders.Spam)) 2181 2471 } 2182 2472 if key == "d" { // gd — go to Drafts (not in tab rotation) 2183 2473 m.loading = true 2184 2474 m.offTabFolder = "Drafts" 2475 + m.imapSearchText = "" 2185 2476 m.status = "Drafts folder — press R to reload, tab to leave" 2186 2477 return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.cfg.Folders.Drafts)) 2187 2478 } ··· 2210 2501 } 2211 2502 m.activeFolderI = i 2212 2503 m.offTabFolder = "" 2504 + m.imapSearchText = "" 2213 2505 m.loading = true 2214 2506 return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) 2215 2507 } ··· 2230 2522 "t": m.cfg.Folders.Trash, 2231 2523 "o": m.cfg.Folders.ScreenedOut, 2232 2524 "w": m.cfg.Folders.Waiting, 2233 - "b": m.cfg.Folders.Work, 2234 2525 "m": m.cfg.Folders.Someday, 2235 2526 "k": m.cfg.Folders.ToScreen, 2527 + } 2528 + // Only add Work folder if configured 2529 + if m.cfg.Folders.Work != "" { 2530 + dstMap["b"] = m.cfg.Folders.Work 2236 2531 } 2237 2532 if dst, ok := dstMap[key]; ok { 2238 2533 m.loading = true ··· 2406 2701 if a.ContentID == "" || len(a.Data) == 0 { 2407 2702 continue 2408 2703 } 2409 - imgPath := filepath.Join(neomdTempDir(), "cid-"+a.ContentID+"-"+a.Filename) 2704 + // Sanitize ContentID and Filename to prevent path traversal attacks 2705 + safeCID := strings.ReplaceAll(a.ContentID, string(os.PathSeparator), "_") 2706 + safeCID = strings.ReplaceAll(safeCID, "..", "_") 2707 + safeName := filepath.Base(a.Filename) 2708 + 2709 + imgPath := filepath.Join(neomdTempDir(), "cid-"+safeCID+"-"+safeName) 2710 + 2711 + // Verify the path is still under neomdTempDir() 2712 + if !strings.HasPrefix(imgPath, neomdTempDir()) { 2713 + continue 2714 + } 2715 + 2410 2716 if err := os.WriteFile(imgPath, a.Data, 0600); err != nil { 2411 2717 continue 2412 2718 } 2413 2719 tmpImages = append(tmpImages, imgPath) 2414 - // Replace cid:XYZ with file:///path (case-insensitive) 2720 + // Replace cid:XYZ with file:///path (case-sensitive match) 2415 2721 htmlBody = strings.ReplaceAll(htmlBody, "cid:"+a.ContentID, "file://"+imgPath) 2416 2722 } 2417 2723 ··· 2612 2918 e := m.openEmail 2613 2919 to := e.To 2614 2920 cc := e.CC 2921 + bcc := e.BCC 2922 + from := e.From 2615 2923 subject := e.Subject 2616 2924 2617 2925 // Pre-fill compose fields so viewCompose shows them 2618 2926 m.compose.reset() 2619 - m.presendFromI = 0 2927 + if idx := m.matchFromAddress(from); idx >= 0 { 2928 + m.presendFromI = idx 2929 + } else { 2930 + m.presendFromI = 0 2931 + } 2620 2932 m.compose.to.SetValue(to) 2621 2933 m.compose.cc.SetValue(cc) 2934 + m.compose.bcc.SetValue(bcc) 2622 2935 m.compose.subject.SetValue(subject) 2623 - if cc != "" { 2936 + if cc != "" || bcc != "" { 2624 2937 m.compose.extraVisible = true 2625 2938 } 2626 2939 m.compose.step = 3 // jump past header steps to subject-done state 2940 + if len(m.openAttachments) > 0 { 2941 + paths, err := writeAttachmentsTemp(m.openAttachments) 2942 + if err != nil { 2943 + m.status = "continueDraft attachments: " + err.Error() 2944 + m.isError = true 2945 + return m, nil 2946 + } 2947 + m.attachments = paths 2948 + } else { 2949 + m.attachments = nil 2950 + } 2627 2951 2628 2952 // Build temp file with prelude + existing body. 2629 2953 // No signature — the draft body already contains it from the first compose. 2630 - prelude := editor.Prelude(to, cc, subject, "") 2954 + prelude := editor.Prelude(to, cc, bcc, m.presendFrom(), subject, "") 2631 2955 body := m.openBody 2632 2956 2633 2957 f, err := os.CreateTemp(neomdTempDir(), "neomd-*.md") ··· 2647 2971 cmd := exec.Command(editorBin, tmpPath) 2648 2972 draftBackups := m.cfg.UI.DraftBackups() 2649 2973 m.state = stateCompose 2974 + m.status = "" 2975 + m.isError = false 2650 2976 return m, tea.ExecProcess(cmd, func(execErr error) tea.Msg { 2651 2977 backupDraft(tmpPath, draftBackups) 2652 2978 defer os.Remove(tmpPath) ··· 2660 2986 if string(raw) == prelude+body { 2661 2987 return editorDoneMsg{aborted: true} 2662 2988 } 2663 - pto, pcc, _, psubject, _ := editor.ParseHeaders(string(raw)) 2989 + pto, pcc, pbcc, pfrom, psubject, _ := editor.ParseHeaders(string(raw)) 2664 2990 if pto == "" { 2665 2991 pto = to 2666 2992 } 2667 2993 if pcc == "" { 2668 2994 pcc = cc 2669 2995 } 2996 + if pbcc == "" { 2997 + pbcc = bcc 2998 + } 2999 + if pfrom == "" { 3000 + pfrom = m.presendFrom() 3001 + } 2670 3002 if psubject == "" { 2671 3003 psubject = subject 2672 3004 } 2673 - return editorDoneMsg{to: pto, cc: pcc, bcc: "", subject: psubject, body: string(raw)} 3005 + return editorDoneMsg{to: pto, cc: pcc, bcc: pbcc, from: pfrom, subject: psubject, body: string(raw)} 2674 3006 }) 2675 3007 } 2676 3008 2677 3009 func (m Model) updateCompose(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 3010 + if m.pendingDiscard { 3011 + switch msg.String() { 3012 + case "y": 3013 + m.pendingDiscard = false 3014 + m.attachments = nil 3015 + m.pendingSend = nil 3016 + m.state = stateInbox 3017 + m.status = "Discarded. Use :recover to reopen the latest backup if needed." 3018 + m.isError = false 3019 + return m, nil 3020 + case "n", "esc": 3021 + m.cancelDiscardConfirm() 3022 + return m, nil 3023 + default: 3024 + return m, nil 3025 + } 3026 + } 3027 + 2678 3028 switch msg.String() { 2679 3029 case "esc": 2680 - m.attachments = nil 3030 + if m.hasComposeDraft() { 3031 + m.beginDiscardConfirm() 3032 + return m, nil 3033 + } 2681 3034 m.state = stateInbox 3035 + m.status = "Cancelled." 2682 3036 return m, nil 2683 3037 case "ctrl+t": 2684 3038 return m.launchAttachPickerCmd() ··· 2690 3044 return m, nil 2691 3045 case "ctrl+f": 2692 3046 froms := m.presendFroms() 2693 - if len(froms) > 1 { 2694 - m.presendFromI = (m.presendFromI + 1) % len(froms) 3047 + if len(froms) <= 1 { 3048 + m.status = "Only one From address configured. Add another account or [[senders]] alias to cycle." 3049 + return m, nil 2695 3050 } 3051 + m.presendFromI = (m.presendFromI + 1) % len(froms) 2696 3052 return m, nil 2697 3053 } 3054 + if m.status != "" { 3055 + m.status = "" 3056 + m.isError = false 3057 + } 2698 3058 2699 3059 var cmd tea.Cmd 2700 3060 var launch bool ··· 2713 3073 m.state = stateInbox 2714 3074 return m, nil 2715 3075 } 3076 + if m.pendingDiscard { 3077 + switch msg.String() { 3078 + case "y": 3079 + m.pendingDiscard = false 3080 + m.attachments = nil 3081 + m.pendingSend = nil 3082 + m.state = stateInbox 3083 + m.status = "Discarded. Use :recover to reopen the latest backup if needed." 3084 + m.isError = false 3085 + return m, nil 3086 + case "n", "esc": 3087 + m.cancelDiscardConfirm() 3088 + return m, nil 3089 + default: 3090 + return m, nil 3091 + } 3092 + } 2716 3093 switch msg.String() { 2717 3094 case "enter": 2718 3095 m.loading = true ··· 2723 3100 replyUID, replyFolder := ps.replyToUID, ps.replyToFolder 2724 3101 m.attachments = nil 2725 3102 m.pendingSend = nil 2726 - return m, tea.Batch(m.spinner.Tick, m.sendEmailCmd(smtpAcct, from, ps.to, ps.cc, ps.bcc, ps.subject, ps.body, attachments, replyUID, replyFolder)) 3103 + return m, tea.Batch(m.spinner.Tick, m.sendEmailCmd(smtpAcct, from, ps.to, ps.cc, ps.bcc, ps.subject, ps.body, attachments, replyUID, replyFolder, ps.replyToAccount)) 2727 3104 case "ctrl+f": 2728 3105 froms := m.presendFroms() 2729 - if len(froms) > 1 { 2730 - m.presendFromI = (m.presendFromI + 1) % len(froms) 3106 + if len(froms) <= 1 { 3107 + m.status = "Only one From address configured. Add another account or [[senders]] alias to cycle." 3108 + return m, nil 2731 3109 } 3110 + m.presendFromI = (m.presendFromI + 1) % len(froms) 2732 3111 return m, nil 2733 3112 case "a": 2734 3113 return m.launchAttachPickerCmd() ··· 2754 3133 return m.launchSpellCheckCmd(ps) 2755 3134 case "d": 2756 3135 // Save to Drafts without sending. 2757 - return m, m.saveDraftCmd(m.presendFrom(), ps.to, ps.cc, ps.subject, ps.body, m.attachments) 3136 + return m, m.saveDraftCmd(m.presendIMAPClient(), m.presendFrom(), ps.to, ps.cc, ps.bcc, ps.subject, ps.body, m.attachments) 2758 3137 case "ctrl+b": 2759 3138 // Toggle CC/BCC fields — show input prompts to add/edit them. 2760 3139 m.compose.extraVisible = !m.compose.extraVisible ··· 2768 3147 } 2769 3148 return m, nil 2770 3149 case "x": 2771 - // Discard the email entirely — clear everything and go back to inbox. 2772 - m.attachments = nil 2773 - m.pendingSend = nil 2774 - m.state = stateInbox 2775 - m.status = "Discarded." 2776 - m.isError = false 3150 + m.beginDiscardConfirm() 2777 3151 return m, nil 2778 3152 case "p": 2779 3153 return m.previewInBrowser() 2780 3154 case "esc": 2781 - m.attachments = nil 2782 - m.pendingSend = nil 2783 - m.state = stateInbox 2784 - m.status = "Cancelled." 3155 + m.beginDiscardConfirm() 2785 3156 return m, nil 3157 + } 3158 + if m.status != "" { 3159 + m.status = "" 3160 + m.isError = false 2786 3161 } 2787 3162 return m, nil 2788 3163 } ··· 2791 3166 // enabled and the cursor positioned on the first misspelled word. 2792 3167 // On return, the (possibly corrected) body replaces the pre-send body. 2793 3168 func (m Model) launchSpellCheckCmd(ps *pendingSendData) (tea.Model, tea.Cmd) { 2794 - prelude := editor.Prelude(ps.to, ps.cc, ps.subject, "") 3169 + prelude := editor.Prelude(ps.to, ps.cc, ps.bcc, m.presendFrom(), ps.subject, "") 2795 3170 content := prelude + ps.body 2796 3171 2797 3172 f, err := os.CreateTemp(neomdTempDir(), "neomd-*.md") ··· 2822 3197 if readErr != nil { 2823 3198 return editorDoneMsg{err: readErr} 2824 3199 } 2825 - pto, pcc, pbcc, psubject, _ := editor.ParseHeaders(string(raw)) 3200 + pto, pcc, pbcc, pfrom, psubject, _ := editor.ParseHeaders(string(raw)) 2826 3201 if pto == "" { 2827 3202 pto = ps.to 2828 3203 } ··· 2832 3207 if pbcc == "" { 2833 3208 pbcc = ps.bcc 2834 3209 } 3210 + if pfrom == "" { 3211 + pfrom = m.presendFrom() 3212 + } 2835 3213 if psubject == "" { 2836 3214 psubject = ps.subject 2837 3215 } 2838 - return editorDoneMsg{to: pto, cc: pcc, bcc: pbcc, subject: psubject, body: string(raw)} 3216 + return editorDoneMsg{to: pto, cc: pcc, bcc: pbcc, from: pfrom, subject: psubject, body: string(raw)} 2839 3217 }) 2840 3218 } 2841 3219 ··· 2886 3264 } 2887 3265 } 2888 3266 2889 - func (m Model) saveDraftCmd(from, to, cc, subject, body string, attachments []string) tea.Cmd { 2890 - cli := m.imapCli() 3267 + func (m Model) saveDraftCmd(imapCli *imap.Client, from, to, cc, bcc, subject, body string, attachments []string) tea.Cmd { 2891 3268 folder := m.cfg.Folders.Drafts 2892 3269 return func() tea.Msg { 2893 - raw, err := smtp.BuildMessage(from, to, cc, subject, body, attachments) 3270 + raw, err := smtp.BuildDraftMessage(from, to, cc, bcc, subject, body, attachments) 2894 3271 if err != nil { 2895 3272 return saveDraftDoneMsg{err: err} 2896 3273 } 2897 - err = cli.SaveDraft(nil, folder, raw) 3274 + err = imapCli.SaveDraft(nil, folder, raw) 2898 3275 return saveDraftDoneMsg{err: err} 2899 3276 } 2900 3277 } ··· 2904 3281 cc := m.compose.cc.Value() 2905 3282 bcc := m.compose.bcc.Value() 2906 3283 subject := m.compose.subject.Value() 2907 - prelude := editor.Prelude(to, cc, subject, m.cfg.UI.Signature) 3284 + prelude := editor.Prelude(to, cc, bcc, m.presendFrom(), subject, m.cfg.UI.Signature) 2908 3285 2909 3286 // Write temp file 2910 3287 f, err := os.CreateTemp(neomdTempDir(), "neomd-*.md") ··· 2938 3315 if string(raw) == prelude { 2939 3316 return editorDoneMsg{aborted: true} 2940 3317 } 2941 - pto, pcc, pbcc, psubject, _ := editor.ParseHeaders(string(raw)) 3318 + pto, pcc, pbcc, pfrom, psubject, _ := editor.ParseHeaders(string(raw)) 2942 3319 if pto == "" { 2943 3320 pto = to 2944 3321 } ··· 2947 3324 } 2948 3325 if pbcc == "" { 2949 3326 pbcc = bcc 3327 + } 3328 + if pfrom == "" { 3329 + pfrom = m.presendFrom() 2950 3330 } 2951 3331 if psubject == "" { 2952 3332 psubject = subject 2953 3333 } 2954 - return editorDoneMsg{to: pto, cc: pcc, bcc: pbcc, subject: psubject, body: string(raw)} 3334 + return editorDoneMsg{to: pto, cc: pcc, bcc: pbcc, from: pfrom, subject: psubject, body: string(raw)} 2955 3335 }) 2956 3336 } 2957 3337 ··· 2959 3339 // the pre-send screen). The prelude is built from the provided headers (no 2960 3340 // signature — it is already in the body from the first compose). 2961 3341 func (m Model) launchEditorWithBodyCmd(to, cc, bcc, subject, body string) (tea.Model, tea.Cmd) { 2962 - prelude := editor.Prelude(to, cc, subject, "") 3342 + prelude := editor.Prelude(to, cc, bcc, m.presendFrom(), subject, "") 2963 3343 content := prelude + body 2964 3344 2965 3345 f, err := os.CreateTemp(neomdTempDir(), "neomd-*.md") ··· 2993 3373 if string(raw) == content { 2994 3374 return editorDoneMsg{aborted: true} 2995 3375 } 2996 - pto, pcc, pbcc, psubject, _ := editor.ParseHeaders(string(raw)) 3376 + pto, pcc, pbcc, pfrom, psubject, _ := editor.ParseHeaders(string(raw)) 2997 3377 if pto == "" { 2998 3378 pto = to 2999 3379 } ··· 3003 3383 if pbcc == "" { 3004 3384 pbcc = bcc 3005 3385 } 3386 + if pfrom == "" { 3387 + pfrom = m.presendFrom() 3388 + } 3006 3389 if psubject == "" { 3007 3390 psubject = subject 3008 3391 } 3009 - return editorDoneMsg{to: pto, cc: pcc, bcc: pbcc, subject: psubject, body: string(raw)} 3392 + return editorDoneMsg{to: pto, cc: pcc, bcc: pbcc, from: pfrom, subject: psubject, body: string(raw)} 3010 3393 }) 3011 3394 } 3012 3395 ··· 3096 3479 if string(raw) == prelude { 3097 3480 return editorDoneMsg{aborted: true} 3098 3481 } 3099 - pto, _, _, psubject, _ := editor.ParseHeaders(string(raw)) 3482 + pto, _, _, pfrom, psubject, _ := editor.ParseHeaders(string(raw)) 3483 + if pfrom == "" { 3484 + pfrom = m.presendFrom() 3485 + } 3100 3486 if psubject == "" { 3101 3487 if !strings.HasPrefix(strings.ToLower(subject), "fwd:") { 3102 3488 psubject = "Fwd: " + subject ··· 3104 3490 psubject = subject 3105 3491 } 3106 3492 } 3107 - return editorDoneMsg{to: pto, cc: "", bcc: "", subject: psubject, body: string(raw)} 3493 + return editorDoneMsg{to: pto, cc: "", bcc: "", from: pfrom, subject: psubject, body: string(raw)} 3108 3494 }) 3109 3495 } 3110 3496 ··· 3186 3572 if string(raw) == prelude { 3187 3573 return editorDoneMsg{aborted: true} 3188 3574 } 3189 - pto, pcc, _, psubject, _ := editor.ParseHeaders(string(raw)) 3575 + pto, pcc, _, pfrom, psubject, _ := editor.ParseHeaders(string(raw)) 3190 3576 if pto == "" { 3191 3577 pto = to 3192 3578 } 3193 3579 if pcc == "" { 3194 3580 pcc = cc 3195 3581 } 3582 + if pfrom == "" { 3583 + pfrom = m.presendFrom() 3584 + } 3196 3585 if psubject == "" { 3197 3586 psubject = subject 3198 3587 } 3199 - return editorDoneMsg{to: pto, cc: pcc, bcc: "", subject: psubject, body: string(raw)} 3588 + return editorDoneMsg{to: pto, cc: pcc, bcc: "", from: pfrom, subject: psubject, body: string(raw)} 3200 3589 }) 3201 3590 } 3202 3591 ··· 3219 3608 return -1 3220 3609 } 3221 3610 3611 + func (m Model) matchFromAddress(from string) int { 3612 + target := strings.ToLower(extractEmailAddr(from)) 3613 + if target == "" { 3614 + return -1 3615 + } 3616 + for i, candidate := range m.presendFroms() { 3617 + if strings.ToLower(extractEmailAddr(candidate)) == target { 3618 + return i 3619 + } 3620 + } 3621 + return -1 3622 + } 3623 + 3222 3624 // extractEmailAddr returns the bare email address from "Name <addr>" or "addr". 3223 3625 // mergeAutoBCC appends autoBCC to the existing bcc field, deduped by email 3224 3626 // address. Returns bcc unchanged when autoBCC is empty or already present. ··· 3388 3790 } 3389 3791 b.WriteString("\n") 3390 3792 } 3391 - 3392 - b.WriteString(styleHelp.Render(" enter send · s spell · p preview · a attach · D remove attach · ctrl+f from · ctrl+b cc/bcc · e edit · d draft · esc cancel · x discard")) 3793 + if m.status != "" { 3794 + b.WriteString(statusBar(m.status, m.isError)) 3795 + } else { 3796 + b.WriteString(styleHelp.Render(" enter send · e edit · p preview · a attach · D remove attach · ctrl+f from · ctrl+b cc/bcc · d draft · esc cancel · x discard")) 3797 + } 3393 3798 return b.String() 3394 3799 } 3395 3800 ··· 3445 3850 if len(m.accounts) > 1 { 3446 3851 help += styleHelp.Render(" · ctrl+a switch account") 3447 3852 } 3853 + if len(m.emails) > 0 { 3854 + help += styleDate.Render(fmt.Sprintf(" │ %d loaded", len(m.emails))) 3855 + } 3448 3856 b.WriteString(help) 3449 3857 } 3450 3858 return b.String() ··· 3483 3891 } 3484 3892 b.WriteString("\n") 3485 3893 } 3486 - b.WriteString(composeHelp(int(m.compose.step), len(m.presendFroms()) > 1)) 3894 + if m.status != "" { 3895 + b.WriteString(statusBar(m.status, m.isError)) 3896 + } else { 3897 + b.WriteString(composeHelp(int(m.compose.step), len(m.presendFroms()) > 1)) 3898 + } 3487 3899 return b.String() 3488 3900 } 3489 3901 3490 3902 func (m Model) updateHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 3491 3903 switch msg.String() { 3492 3904 case "esc": 3493 - if m.helpSearch != "" { 3494 - m.helpSearch = "" // first esc clears filter 3905 + if m.helpSearchActive { 3906 + if m.helpSearch != "" { 3907 + m.helpSearch = "" 3908 + m.helpScroll = 0 3909 + } else { 3910 + m.helpSearchActive = false 3911 + } 3912 + } else if m.helpSearch != "" { 3913 + m.helpSearch = "" 3914 + m.helpScroll = 0 3495 3915 } else { 3496 3916 m.state = m.prevState 3497 3917 } 3918 + case "enter": 3919 + if m.helpSearchActive { 3920 + m.helpSearchActive = false 3921 + } 3498 3922 case "q": 3499 - if m.helpSearch == "" { 3923 + if !m.helpSearchActive { 3500 3924 m.state = m.prevState 3501 3925 } else { 3502 3926 m.helpSearch += "q" 3503 3927 } 3504 - case "backspace": 3505 - if len(m.helpSearch) > 0 { 3506 - m.helpSearch = m.helpSearch[:len([]rune(m.helpSearch))-1] 3928 + case "backspace", "ctrl+h": 3929 + if m.helpSearchActive && len(m.helpSearch) > 0 { 3930 + runes := []rune(m.helpSearch) 3931 + m.helpSearch = string(runes[:len(runes)-1]) 3932 + m.helpScroll = 0 3507 3933 } 3508 3934 case "/": 3509 - // already in search mode — "/" is just a printable char if search active 3510 - if m.helpSearch == "" { 3511 - // start typing to search; "/" itself doesn't appear 3935 + if m.helpSearchActive { 3936 + m.helpSearch += "/" 3937 + m.helpScroll = 0 3512 3938 } else { 3513 - m.helpSearch += "/" 3939 + m.helpSearchActive = true 3940 + } 3941 + case "j", "down": 3942 + if !m.helpSearchActive { 3943 + m.helpScroll++ 3944 + } 3945 + case "k", "up": 3946 + if !m.helpSearchActive && m.helpScroll > 0 { 3947 + m.helpScroll-- 3948 + } 3949 + case "d", "ctrl+d": 3950 + if !m.helpSearchActive { 3951 + m.helpScroll += m.helpPageSize() 3952 + } 3953 + case "u", "ctrl+u": 3954 + if !m.helpSearchActive { 3955 + m.helpScroll -= m.helpPageSize() 3514 3956 } 3515 3957 default: 3516 - // printable single character: append to search 3517 - if len(msg.String()) == 1 { 3958 + if m.helpSearchActive && len(msg.String()) == 1 { 3518 3959 m.helpSearch += msg.String() 3960 + m.helpScroll = 0 3519 3961 } 3520 3962 } 3963 + m.clampHelpScroll() 3521 3964 return m, nil 3522 3965 } 3523 3966 ··· 3540 3983 title.Render("Quick start") + "\n" + 3541 3984 key.Render(" j/k") + " navigate " + key.Render("enter") + " open email\n" + 3542 3985 key.Render(" c") + " compose " + key.Render("r") + " reply\n" + 3543 - key.Render(" f") + " forward " + key.Render("R") + " reply-all\n" + 3986 + key.Render(" f") + " forward " + key.Render("ctrl+r") + " reply-all\n" + 3544 3987 key.Render(" ]") + " / " + key.Render("[") + " next/prev tab " + key.Render("?") + " all keys\n\n" + 3545 3988 title.Render("How the Screener works") + "\n" + 3546 3989 "Your screener lists are empty, so " + warn.Render("auto-screening") + "\n" + 3547 3990 warn.Render("is paused") + " until you classify your first senders.\n\n" + 3548 3991 title.Render("Getting started") + "\n" + 3549 - "1. Go to " + key.Render("ToScreen") + " tab (" + key.Render("gk") + " or " + key.Render("Tab") + " or click)\n" + 3992 + "1. Go to " + key.Render("Inbox") + " tab; once screener is active, use " + key.Render("ToScreen") + " (" + key.Render("gk") + " or " + key.Render("Tab") + " or click)\n" + 3550 3993 "2. Screen each sender:\n" + 3551 3994 key.Render(" I") + " screen " + title.Render("in") + " " + dim.Render("sender stays in Inbox forever") + "\n" + 3552 3995 key.Render(" O") + " screen " + title.Render("out") + " " + dim.Render("sender never reaches Inbox again") + "\n" + 3553 3996 key.Render(" F") + " feed " + dim.Render("newsletters go to Feed tab") + "\n" + 3554 3997 key.Render(" P") + " papertrail " + dim.Render("receipts go to PaperTrail tab") + "\n" + 3555 - "3. Use " + key.Render("m") + " to mark multiple, then " + key.Render("I") + " to batch-approve\n\n" + 3998 + "3. Use " + key.Render("m") + " to mark multiple, then " + key.Render("I") + " to batch-approve\n" + 3999 + "4. Normal loads only screen the newest " + key.Render(strconv.Itoa(m.cfg.UI.InboxCount)) + " Inbox emails\n" + 4000 + "5. Use " + key.Render(":screen-all") + " for the full Inbox on the server " + dim.Render("(slower; mailbox-wide)") + "\n\n" + 3556 4001 dim.Render("Once classified, senders are remembered forever.") + "\n" + 3557 4002 dim.Render("New emails auto-sort on every load. You choose") + "\n" + 3558 4003 dim.Render("who lands in your inbox. Bye-bye spam.") + "\n\n" + 4004 + dim.Render("Inline <leader>a attachments in nvim require custom.lua + yazi.") + "\n" + 3559 4005 dim.Render("Disable auto-screen: auto_screen_on_load = false") + "\n" + 3560 4006 dim.Render("Diagnostics: :debug All keys: ?") + "\n\n" + 3561 4007 dim.Render("Press any key to continue.") ··· 3594 4040 3595 4041 filter := strings.ToLower(m.helpSearch) 3596 4042 3597 - var b strings.Builder 3598 - b.WriteString(heading + "\n" + sep + "\n") 4043 + lines := []string{heading, sep} 3599 4044 for _, sec := range HelpSections { 3600 4045 var matched [][2]string 3601 4046 for _, row := range sec.Rows { ··· 3606 4051 if len(matched) == 0 { 3607 4052 continue 3608 4053 } 3609 - b.WriteString("\n" + titleStyle.Render(" "+sec.Title) + "\n") 4054 + lines = append(lines, "", titleStyle.Render(" "+sec.Title)) 3610 4055 for _, row := range matched { 3611 - b.WriteString(" " + keyStyle.Render(row[0]) + descStyle.Render(row[1]) + "\n") 4056 + lines = append(lines, " "+keyStyle.Render(row[0])+descStyle.Render(row[1])) 3612 4057 } 3613 4058 } 3614 4059 3615 - // Search bar 3616 4060 var searchLine string 3617 - if filter != "" { 3618 - searchLine = matchStyle.Render(" /"+m.helpSearch) + styleHelp.Render(" · esc to clear") 4061 + if m.helpSearchActive { 4062 + searchLine = matchStyle.Render(" /"+m.helpSearch+"█") + styleHelp.Render(" · enter done · esc clear") 4063 + } else if filter != "" { 4064 + searchLine = matchStyle.Render(" /"+m.helpSearch) + styleHelp.Render(" · j/k scroll · / edit filter · esc clear") 3619 4065 } else { 3620 - searchLine = styleHelp.Render(" type to filter · ? or q to close") 4066 + searchLine = styleHelp.Render(" j/k scroll · d/u page · / filter · ? or q close") 3621 4067 } 3622 - b.WriteString("\n" + searchLine) 4068 + 4069 + contentHeight := m.height - 1 4070 + if contentHeight < 1 { 4071 + contentHeight = len(lines) 4072 + } 4073 + start := m.helpScroll 4074 + if start < 0 { 4075 + start = 0 4076 + } 4077 + maxStart := len(lines) - contentHeight 4078 + if maxStart < 0 { 4079 + maxStart = 0 4080 + } 4081 + if start > maxStart { 4082 + start = maxStart 4083 + } 4084 + end := start + contentHeight 4085 + if end > len(lines) { 4086 + end = len(lines) 4087 + } 4088 + 4089 + var b strings.Builder 4090 + for _, line := range lines[start:end] { 4091 + b.WriteString(line + "\n") 4092 + } 4093 + b.WriteString(searchLine) 3623 4094 return b.String() 4095 + } 4096 + 4097 + func (m Model) helpPageSize() int { 4098 + if m.height <= 8 { 4099 + return 1 4100 + } 4101 + return (m.height - 4) / 2 4102 + } 4103 + 4104 + func (m Model) helpContentLineCount() int { 4105 + filter := strings.ToLower(m.helpSearch) 4106 + count := 2 4107 + for _, sec := range HelpSections { 4108 + matched := 0 4109 + for _, row := range sec.Rows { 4110 + if filter == "" || strings.Contains(strings.ToLower(row[0]), filter) || strings.Contains(strings.ToLower(row[1]), filter) { 4111 + matched++ 4112 + } 4113 + } 4114 + if matched == 0 { 4115 + continue 4116 + } 4117 + count += 2 + matched 4118 + } 4119 + return count 4120 + } 4121 + 4122 + func (m *Model) clampHelpScroll() { 4123 + if m.helpScroll < 0 { 4124 + m.helpScroll = 0 4125 + } 4126 + contentHeight := m.height - 1 4127 + if contentHeight < 1 { 4128 + contentHeight = 1 4129 + } 4130 + maxScroll := m.helpContentLineCount() - contentHeight 4131 + if maxScroll < 0 { 4132 + maxScroll = 0 4133 + } 4134 + if m.helpScroll > maxScroll { 4135 + m.helpScroll = maxScroll 4136 + } 3624 4137 } 3625 4138 3626 4139 // ── Helpers ───────────────────────────────────────────────────────────────
+236
internal/ui/model_test.go
··· 1 1 package ui 2 2 3 3 import ( 4 + "reflect" 4 5 "strings" 5 6 "testing" 7 + 8 + tea "github.com/charmbracelet/bubbletea" 9 + "github.com/sspaeti/neomd/internal/config" 10 + "github.com/sspaeti/neomd/internal/imap" 6 11 ) 7 12 8 13 func TestMaskEmail(t *testing.T) { ··· 57 62 }) 58 63 } 59 64 } 65 + 66 + func TestMergeAutoBCC(t *testing.T) { 67 + tests := []struct { 68 + name string 69 + bcc string 70 + autoBCC string 71 + want string 72 + }{ 73 + { 74 + name: "append when empty", 75 + bcc: "", 76 + autoBCC: "archive@example.com", 77 + want: "archive@example.com", 78 + }, 79 + { 80 + name: "append when distinct", 81 + bcc: "team@example.com", 82 + autoBCC: "archive@example.com", 83 + want: "team@example.com, archive@example.com", 84 + }, 85 + { 86 + name: "dedupe bare and named address", 87 + bcc: "Archive <archive@example.com>", 88 + autoBCC: "archive@example.com", 89 + want: "Archive <archive@example.com>", 90 + }, 91 + } 92 + 93 + for _, tt := range tests { 94 + t.Run(tt.name, func(t *testing.T) { 95 + if got := mergeAutoBCC(tt.bcc, tt.autoBCC); got != tt.want { 96 + t.Fatalf("mergeAutoBCC(%q, %q) = %q, want %q", tt.bcc, tt.autoBCC, got, tt.want) 97 + } 98 + }) 99 + } 100 + } 101 + 102 + func TestCollectRcptTo(t *testing.T) { 103 + got := collectRcptTo( 104 + "Alice <alice@example.com>, bob@example.com", 105 + "bob@example.com, Carol <carol@example.com>", 106 + "alice@example.com, dave@example.com", 107 + ) 108 + want := []string{"alice@example.com", "bob@example.com", "carol@example.com", "dave@example.com"} 109 + if !reflect.DeepEqual(got, want) { 110 + t.Fatalf("collectRcptTo() = %#v, want %#v", got, want) 111 + } 112 + } 113 + 114 + func TestPresendSMTPAccount(t *testing.T) { 115 + cfg := &config.Config{ 116 + Accounts: []config.AccountConfig{ 117 + {Name: "Personal", From: "me@example.com"}, 118 + {Name: "Work", From: "me@work.example"}, 119 + }, 120 + Senders: []config.SenderConfig{ 121 + {Name: "Support", From: "support@example.com", Account: "Work"}, 122 + }, 123 + } 124 + m := Model{ 125 + cfg: cfg, 126 + accounts: cfg.ActiveAccounts(), 127 + accountI: 0, 128 + } 129 + 130 + t.Run("selected account uses its own SMTP account", func(t *testing.T) { 131 + m.presendFromI = 1 132 + if got := m.presendSMTPAccount().Name; got != "Work" { 133 + t.Fatalf("presendSMTPAccount() = %q, want %q", got, "Work") 134 + } 135 + }) 136 + 137 + t.Run("sender alias resolves to referenced account", func(t *testing.T) { 138 + m.presendFromI = 2 139 + if got := m.presendSMTPAccount().Name; got != "Work" { 140 + t.Fatalf("presendSMTPAccount() = %q, want %q", got, "Work") 141 + } 142 + }) 143 + } 144 + 145 + func TestMatchFromAddress(t *testing.T) { 146 + cfg := &config.Config{ 147 + Accounts: []config.AccountConfig{ 148 + {Name: "Personal", From: "Me <me@example.com>"}, 149 + }, 150 + Senders: []config.SenderConfig{ 151 + {Name: "Work", From: "Me <me@work.example>"}, 152 + }, 153 + } 154 + m := Model{cfg: cfg, accounts: cfg.ActiveAccounts()} 155 + if got := m.matchFromAddress("me@work.example"); got != 1 { 156 + t.Fatalf("matchFromAddress() = %d, want 1", got) 157 + } 158 + } 159 + 160 + func TestActiveFolderUsesOffTabFolder(t *testing.T) { 161 + m := Model{ 162 + cfg: &config.Config{ 163 + Folders: config.FoldersConfig{ 164 + Inbox: "INBOX", 165 + Drafts: "Drafts", 166 + Spam: "Spam", 167 + }, 168 + }, 169 + folders: []string{"Inbox"}, 170 + activeFolderI: 0, 171 + } 172 + 173 + m.offTabFolder = "Drafts" 174 + if got := m.activeFolder(); got != "Drafts" { 175 + t.Fatalf("activeFolder() with Drafts off-tab = %q, want %q", got, "Drafts") 176 + } 177 + 178 + m.offTabFolder = "Spam" 179 + if got := m.activeFolder(); got != "Spam" { 180 + t.Fatalf("activeFolder() with Spam off-tab = %q, want %q", got, "Spam") 181 + } 182 + } 183 + 184 + func TestUpdateInboxEscClearsCommittedFilter(t *testing.T) { 185 + m := Model{ 186 + filterText: "invoice", 187 + inbox: newInboxList(80, 20, "", ""), 188 + folders: []string{"Inbox"}, 189 + cfg: &config.Config{ 190 + Folders: config.FoldersConfig{Inbox: "INBOX"}, 191 + }, 192 + } 193 + 194 + next, _ := m.updateInbox(tea.KeyMsg{Type: tea.KeyEsc}) 195 + got := next.(Model) 196 + if got.filterText != "" { 197 + t.Fatalf("filterText = %q, want empty", got.filterText) 198 + } 199 + if got.filterActive { 200 + t.Fatal("filterActive should be false after esc") 201 + } 202 + } 203 + 204 + func TestValidateScreenerSafetyRejectsTrashDestination(t *testing.T) { 205 + m := Model{ 206 + cfg: &config.Config{ 207 + Folders: config.FoldersConfig{ 208 + Trash: "Trash", 209 + ScreenedOut: "Trash", 210 + }, 211 + }, 212 + } 213 + 214 + err := m.validateScreenerSafety() 215 + if err == nil { 216 + t.Fatal("expected validateScreenerSafety to fail when ScreenedOut points to Trash") 217 + } 218 + } 219 + 220 + func TestUpdateComposeEscRequestsDiscardConfirmation(t *testing.T) { 221 + m := Model{ 222 + compose: newComposeModel(), 223 + } 224 + m.compose.to.SetValue("alice@example.com") 225 + m.state = stateCompose 226 + 227 + next, _ := m.updateCompose(tea.KeyMsg{Type: tea.KeyEsc}) 228 + got := next.(Model) 229 + if !got.pendingDiscard { 230 + t.Fatal("expected pendingDiscard after esc with unsent compose data") 231 + } 232 + if got.state != stateCompose { 233 + t.Fatalf("state = %v, want compose", got.state) 234 + } 235 + if got.status == "" { 236 + t.Fatal("expected discard confirmation status") 237 + } 238 + } 239 + 240 + func TestUpdateComposeDiscardConfirmationYClearsState(t *testing.T) { 241 + m := Model{ 242 + compose: newComposeModel(), 243 + attachments: []string{"/tmp/file.txt"}, 244 + pendingDiscard: true, 245 + state: stateCompose, 246 + } 247 + m.compose.to.SetValue("alice@example.com") 248 + 249 + next, _ := m.updateCompose(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}) 250 + got := next.(Model) 251 + if got.pendingDiscard { 252 + t.Fatal("pendingDiscard should be cleared after confirming discard") 253 + } 254 + if got.state != stateInbox { 255 + t.Fatalf("state = %v, want inbox", got.state) 256 + } 257 + if len(got.attachments) != 0 { 258 + t.Fatalf("attachments = %#v, want cleared", got.attachments) 259 + } 260 + } 261 + 262 + func TestUpdatePresendEscRequestsDiscardConfirmation(t *testing.T) { 263 + m := Model{ 264 + pendingSend: &pendingSendData{ 265 + to: "alice@example.com", 266 + subject: "hello", 267 + body: "body", 268 + }, 269 + state: statePresend, 270 + } 271 + 272 + next, _ := m.updatePresend(tea.KeyMsg{Type: tea.KeyEsc}) 273 + got := next.(Model) 274 + if !got.pendingDiscard { 275 + t.Fatal("expected pendingDiscard after esc in pre-send") 276 + } 277 + if got.state != statePresend { 278 + t.Fatalf("state = %v, want pre-send", got.state) 279 + } 280 + } 281 + 282 + func TestHandleEverythingResultKeepsRealSubject(t *testing.T) { 283 + m := Model{ 284 + inbox: newInboxList(80, 20, "", ""), 285 + } 286 + msg := everythingResultMsg{ 287 + emails: []imap.Email{{UID: 1, Folder: "Sent", Subject: "Quarterly update"}}, 288 + } 289 + 290 + next, _ := m.handleEverythingResult(msg) 291 + got := next.(*Model) 292 + if got.emails[0].Subject != "Quarterly update" { 293 + t.Fatalf("subject = %q, want unchanged real subject", got.emails[0].Subject) 294 + } 295 + }
+1 -1
internal/ui/reader.go
··· 140 140 141 141 // inboxHelp returns the one-line help string for the inbox view. 142 142 func inboxHelp(folder string) string { 143 - base := []string{"enter/l open", "r reply", "ctrl+r reply-all", "f fwd", "c compose", "I/O/F/P/A screen", "g goto", "M move", "/ filter", "R reload", "? help", "q quit"} 143 + base := []string{"enter/l open", "d/u page", "r reply", "ctrl+r reply-all", "f fwd", "c compose", "I/O/F/P/A screen", "g goto", "M move", ", sort", "/ filter", "R reload", "? help", "q quit"} 144 144 _ = folder 145 145 if folder == "ToScreen" { 146 146 base = []string{"I approve", "O block", "F feed", "P papertrail", "q back"}
+2 -12
internal/ui/search.go
··· 101 101 } 102 102 m.imapSearchResults = true 103 103 m.offTabFolder = "Search" 104 - // Prepend folder name to subject so user can see where each result is from 105 - for i := range msg.emails { 106 - folder := msg.emails[i].Folder 107 - msg.emails[i].Subject = "[" + folder + "] " + msg.emails[i].Subject 108 - } 109 104 m.emails = msg.emails 110 105 m.markedUIDs = make(map[uint32]bool) 111 106 m.filterActive = false ··· 141 136 // handleEverythingResult displays the "Everything" view. 142 137 func (m *Model) handleEverythingResult(msg everythingResultMsg) (tea.Model, tea.Cmd) { 143 138 m.loading = false 139 + m.imapSearchText = "" 144 140 if msg.err != nil { 145 141 m.status = "Everything: " + msg.err.Error() 146 142 m.isError = true ··· 151 147 return m, nil 152 148 } 153 149 m.offTabFolder = "Everything" 154 - // Prepend folder name so user knows where each email is 155 - for i := range msg.emails { 156 - msg.emails[i].Subject = "[" + msg.emails[i].Folder + "] " + msg.emails[i].Subject 157 - } 158 150 m.emails = msg.emails 159 151 m.markedUIDs = make(map[uint32]bool) 160 152 m.filterActive = false ··· 214 206 // handleConversationResult displays the conversation/thread view. 215 207 func (m *Model) handleConversationResult(msg conversationResultMsg) (tea.Model, tea.Cmd) { 216 208 m.loading = false 209 + m.imapSearchResults = false 217 210 if msg.err != nil { 218 211 m.status = "Thread: " + msg.err.Error() 219 212 m.isError = true ··· 224 217 return m, nil 225 218 } 226 219 m.offTabFolder = "Thread" 227 - for i := range msg.emails { 228 - msg.emails[i].Subject = "[" + msg.emails[i].Folder + "] " + msg.emails[i].Subject 229 - } 230 220 m.emails = msg.emails 231 221 m.markedUIDs = make(map[uint32]bool) 232 222 m.filterActive = false