diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 7a1a148..2cc4dc8 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,9 @@ "permissions": { "allow": [ "Bash(go build:*)", - "Bash(go test:*)" + "Bash(go test:*)", + "Bash(make build:*)", + "Bash(make test:*)" ] } } diff --git a/.gitignore b/.gitignore index 591b716..e132304 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ neomd-*.md # OS .DS_Store +.codex diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f37af0..04db02d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +# 2026-04-10 +- **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 +- **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` +- **Fix: committed `/` filter now clears with `esc`** — pressing `esc` now reliably clears the in-memory inbox filter even after the filter was already applied +- **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 +- **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 `a`, while clarifying that pre-send `a` still works independently +- **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 +- **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 +- **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 +- **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 +- **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` +- **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 +- **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 +- **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 +- **Draft MIME keeps `Bcc`** — Drafts saved via IMAP now retain the `Bcc` header so reopening a draft does not silently lose hidden recipients +- **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 +- **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 +- **`:search` help text fixed** — the command description now correctly says it searches across configured folders, not just the current folder + +## Roborev +- **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 +- **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 +- **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 +- **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 +- **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 +- **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") + + # 2026-04-09 - **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 diff --git a/README.md b/README.md index 59b3d58..0b5a4e7 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,12 @@ Or in Gmail: **Prerequisites:** [Go 1.22+](https://go.dev/doc/install) and `make`. +> [!NOTE] +> **Optional attachment helpers:** +> - `yazi` enables the built-in file picker used by pre-send `a` +> - custom Neovim integration in `custom.lua` enables inline `a` attachment insertion inside `neomd-*.md` buffers +> - without these, neomd still works; the inline Neovim attachment workflow just won't be available + ```sh git clone https://github.com/ssp-data/neomd cd neomd @@ -122,9 +128,11 @@ yay -S neomd-bin On first run, neomd: 1. Creates `~/.config/neomd/config.toml` with placeholders — fill in your IMAP/SMTP credentials + - 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. 2. Creates `~/.config/neomd/lists/` for screener allowlists (or uses your custom paths from config) 3. Creates any missing IMAP folders (ToScreen, Feed, PaperTrail, etc.) automatically + Neomd also runs on Android (more for fun) — see [docs/android.md](docs/android.md). ## Configuration @@ -160,6 +168,8 @@ For the full configuration reference including multiple accounts, OAuth2 authent 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. +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. + **Getting started with the screener:** 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,7 +179,7 @@ On first launch, **auto-screening is paused** because your screener lists are em - `O` screen **out** — sender never reaches Inbox again - `F` **feed** — newsletters go to the Feed tab - `P` **papertrail** — receipts go to the PaperTrail tab -4. Use `m` to mark multiple emails, then `I` to batch-approve them all at once. +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. **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. @@ -178,6 +188,9 @@ You choose who can land in your Inbox. Bye-bye spam. This is the beauty of [HEY- > [!TIP] > To disable auto-screening entirely, set `auto_screen_on_load = false` in `[ui]` config. Run `:debug` inside neomd if something isn't working. +> [!WARNING] +> `: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. + ### Screener Workflow Find full Screener Workflow at [docs/screener.md](docs/screener.md), classification tables, and bulk re-classification instructions. @@ -191,6 +204,8 @@ See the [full keybindings reference](docs/keybindings.md) (auto-generated from [ Compose in Markdown, send as `multipart/alternative` (plain text + HTML). Attachments, CC/BCC, multiple From addresses, drafts, and pre-send review are all supported. +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. + - See [docs/sending.md](docs/sending.md) for details on MIME structure, attachments, pre-send review, and drafts. - See [docs/reading.md](docs/reading.md) for the reader: images, inline links, attachments, and navigation. diff --git a/docs/configuration.md b/docs/configuration.md index 515857b..e70f898 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -64,7 +64,7 @@ spam = "spam" #check capitalization of your pre-existing Spam folder, so [ui] theme = "dark" # dark | light | auto -inbox_count = 50 +inbox_count = 200 # how many newest emails neomd loads per folder/reload auto_screen_on_load = true # screen inbox automatically on every load (default true) bg_sync_interval = 5 # background sync interval in minutes; 0 = disabled (default 5) bulk_progress_threshold = 10 # show progress counter for batch operations larger than this (default 10) @@ -83,6 +83,8 @@ Connect: [LinkedIn](https://example.com/) Use an app-specific password (Gmail, Fastmail, Hostpoint, etc.) rather than your main account password. +`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. + ### Environment Variables The `password` and `user` fields support environment variable expansion. If the entire value is a single env var reference, neomd resolves it at startup: diff --git a/docs/keybindings.md b/docs/keybindings.md index 5d22f59..17b9eee 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -12,6 +12,7 @@ Press `?` inside neomd to open the interactive help overlay. Start typing to fil | Key | Action | |-----|--------| | `j / k` | move down / up | +| `d / u` | page down / up in inbox/help | | `gg` | jump to top | | `G` | jump to bottom | | `enter / l` | open email | @@ -34,6 +35,7 @@ Press `?` inside neomd to open the interactive help overlay. Start typing to fil | `gk` | go to ToScreen | | `go` | go to ScreenedOut | | `gw` | go to Waiting | +| `gb` | go to Work (if configured) | | `gm` | go to Someday | | `gd` | go to Drafts | | `ge` | go to Everything — latest 50 emails across all folders | @@ -65,6 +67,7 @@ Press `?` inside neomd to open the interactive help overlay. Start typing to fil | `Mt` | move to Trash | | `Mo` | move to ScreenedOut | | `Mw` | move to Waiting | +| `Mb` | move to Work (if configured) | | `Mm` | move to Someday | | `Mk` | move to ToScreen | diff --git a/internal/config/config.go b/internal/config/config.go index e51028d..855c1ef 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -331,7 +331,7 @@ func defaults() *Config { }, UI: UIConfig{ Theme: "dark", - InboxCount: 50, + InboxCount: 200, BgSyncInterval: 5, Signature: "*sent from [neomd](https://neomd.ssp.sh)*", }, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a1b25ac..3de1b58 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -20,6 +20,13 @@ func TestExpandEnv(t *testing.T) { {"embedded dollar", "literal$value", "", "", "literal$value"}, {"multiple dollars", "pa$$word", "", "", "pa$$word"}, {"empty string", "", "", "", ""}, + {"unset var bare", "$UNSET_NEOMD_VAR", "", "", ""}, + {"unset var braced", "${UNSET_NEOMD_VAR}", "", "", ""}, + {"bare $ alone", "$", "", "", ""}, + {"empty braced ${}", "${}", "", "", ""}, + {"whitespace trimmed", " $MY_VAR ", "MY_VAR", "trimmed", "trimmed"}, + {"$VAR with suffix", "$MY_VAR-suffix", "", "", ""}, + {"text before $VAR", "prefix-$MY_VAR", "", "", "prefix-$MY_VAR"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/editor/editor.go b/internal/editor/editor.go index ec58261..9cf87a1 100644 --- a/internal/editor/editor.go +++ b/internal/editor/editor.go @@ -86,12 +86,19 @@ func View(content string) (*exec.Cmd, string, error) { } // Prelude builds the header shown at the top of a new compose buffer. -// cc may be empty. If signature is non-empty it is appended after a blank line separator. -func Prelude(to, cc, subject, signature string) string { +// cc, bcc, and from may be empty. If signature is non-empty it is appended +// after a blank line separator. +func Prelude(to, cc, bcc, from, subject, signature string) string { s := fmt.Sprintf("# [neomd: to: %s]\n", to) if cc != "" { s += fmt.Sprintf("# [neomd: cc: %s]\n", cc) } + if bcc != "" { + s += fmt.Sprintf("# [neomd: bcc: %s]\n", bcc) + } + if from != "" { + s += fmt.Sprintf("# [neomd: from: %s]\n", from) + } s += fmt.Sprintf("# [neomd: subject: %s]\n\n", subject) if signature != "" { s += "\n\n-- \n" + signature + "\n" @@ -101,29 +108,18 @@ func Prelude(to, cc, subject, signature string) string { // ReplyPrelude builds a quote block for replies. cc and from may be empty. func ReplyPrelude(to, cc, subject, from, originalFrom, originalBody string) string { - s := fmt.Sprintf("# [neomd: to: %s]\n", to) - if cc != "" { - s += fmt.Sprintf("# [neomd: cc: %s]\n", cc) - } - if from != "" { - s += fmt.Sprintf("# [neomd: from: %s]\n", from) - } - s += fmt.Sprintf("# [neomd: subject: %s]\n\n---\n\n> **%s** wrote:\n>\n%s\n\n---\n\n", - subject, originalFrom, quoteLines(originalBody)) - return s + return Prelude(to, cc, "", from, subject, "") + + fmt.Sprintf("---\n\n> **%s** wrote:\n>\n%s\n\n---\n\n", + originalFrom, quoteLines(originalBody)) } // ForwardPrelude builds a quoted forward block. The To field is left empty for // the user to fill in. func ForwardPrelude(subject, from, originalFrom, originalDate, originalTo, originalBody string) string { - s := "# [neomd: to: ]\n" - if from != "" { - s += fmt.Sprintf("# [neomd: from: %s]\n", from) - } if !strings.HasPrefix(strings.ToLower(subject), "fwd:") { subject = "Fwd: " + subject } - s += fmt.Sprintf("# [neomd: subject: %s]\n\n", subject) + s := Prelude("", "", "", from, subject, "") s += "---------- Forwarded message ----------\n" s += fmt.Sprintf("From: %s\n", originalFrom) s += fmt.Sprintf("Date: %s\n", originalDate) @@ -134,9 +130,9 @@ func ForwardPrelude(subject, from, originalFrom, originalDate, originalTo, origi } // ParseHeaders scans raw editor content for # [neomd: key: value] lines and -// returns the extracted to, cc, bcc, subject values and the remaining body +// returns the extracted to, cc, bcc, from, subject values and the remaining body // (with header lines stripped). Any field not found is returned as "". -func ParseHeaders(raw string) (to, cc, bcc, subject, body string) { +func ParseHeaders(raw string) (to, cc, bcc, from, subject, body string) { lines := splitLines(raw) var kept []string for _, line := range lines { @@ -148,6 +144,8 @@ func ParseHeaders(raw string) (to, cc, bcc, subject, body string) { cc = strings.TrimSpace(m[2]) case "bcc": bcc = strings.TrimSpace(m[2]) + case "from": + from = strings.TrimSpace(m[2]) case "subject": subject = strings.TrimSpace(m[2]) } diff --git a/internal/editor/editor_test.go b/internal/editor/editor_test.go index abe7376..b210b4f 100644 --- a/internal/editor/editor_test.go +++ b/internal/editor/editor_test.go @@ -7,23 +7,25 @@ import ( func TestParseHeaders(t *testing.T) { tests := []struct { - name string - input string - wantTo, wantCC, wantBCC, wantSub string - wantBodyContains string // substring the body must contain - wantBodyNotContains string // substring the body must NOT contain + name string + input string + wantTo, wantCC, wantBCC, wantFrom, wantSub string + wantBodyContains string // substring the body must contain + wantBodyNotContains string // substring the body must NOT contain }{ { name: "all fields present", input: "# [neomd: to: alice@example.com]\n" + "# [neomd: cc: bob@example.com]\n" + "# [neomd: bcc: secret@example.com]\n" + + "# [neomd: from: Me ]\n" + "# [neomd: subject: Hello World]\n" + "\n" + "Body text here.\n", wantTo: "alice@example.com", wantCC: "bob@example.com", wantBCC: "secret@example.com", + wantFrom: "Me ", wantSub: "Hello World", wantBodyContains: "Body text here.", wantBodyNotContains: "neomd:", @@ -55,13 +57,13 @@ func TestParseHeaders(t *testing.T) { wantBodyContains: "## Heading", }, { - name: "no headers at all", - input: "Just plain text\nwith multiple lines.\n", - wantTo: "", - wantCC: "", - wantBCC: "", - wantSub: "", - wantBodyContains: "Just plain text", + name: "no headers at all", + input: "Just plain text\nwith multiple lines.\n", + wantTo: "", + wantCC: "", + wantBCC: "", + wantSub: "", + wantBodyContains: "Just plain text", wantBodyNotContains: "", }, { @@ -77,7 +79,7 @@ func TestParseHeaders(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - to, cc, bcc, subject, body := ParseHeaders(tt.input) + to, cc, bcc, from, subject, body := ParseHeaders(tt.input) if to != tt.wantTo { t.Errorf("to = %q, want %q", to, tt.wantTo) } @@ -87,6 +89,9 @@ func TestParseHeaders(t *testing.T) { if bcc != tt.wantBCC { t.Errorf("bcc = %q, want %q", bcc, tt.wantBCC) } + if from != tt.wantFrom { + t.Errorf("from = %q, want %q", from, tt.wantFrom) + } if subject != tt.wantSub { t.Errorf("subject = %q, want %q", subject, tt.wantSub) } @@ -102,12 +107,12 @@ func TestParseHeaders(t *testing.T) { func TestPrelude(t *testing.T) { tests := []struct { - name string - to, cc string - subject string - signature string - wantHas []string // substrings that must appear - wantNot []string // substrings that must NOT appear + name string + to, cc, bcc, from string + subject string + signature string + wantHas []string // substrings that must appear + wantNot []string // substrings that must NOT appear }{ { name: "basic without cc or sig", @@ -117,7 +122,7 @@ func TestPrelude(t *testing.T) { "# [neomd: to: alice@example.com]", "# [neomd: subject: Greetings]", }, - wantNot: []string{"# [neomd: cc:", "-- \n"}, + wantNot: []string{"# [neomd: cc:", "# [neomd: bcc:", "# [neomd: from:", "-- \n"}, }, { name: "with cc", @@ -130,6 +135,17 @@ func TestPrelude(t *testing.T) { "# [neomd: subject: Team]", }, }, + { + name: "with bcc and from", + to: "alice@example.com", + bcc: "secret@example.com", + from: "Me ", + subject: "Private", + wantHas: []string{ + "# [neomd: bcc: secret@example.com]", + "# [neomd: from: Me ]", + }, + }, { name: "with signature", to: "a@b.com", @@ -147,7 +163,7 @@ func TestPrelude(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := Prelude(tt.to, tt.cc, tt.subject, tt.signature) + got := Prelude(tt.to, tt.cc, tt.bcc, tt.from, tt.subject, tt.signature) for _, want := range tt.wantHas { if !strings.Contains(got, want) { t.Errorf("Prelude missing %q, got:\n%s", want, got) @@ -231,9 +247,9 @@ func TestForwardPrelude(t *testing.T) { func TestPreludeParseHeadersRoundTrip(t *testing.T) { tests := []struct { - name string - to, cc string - subject string + name string + to, cc, bcc, from string + subject string }{ { name: "to and subject only", @@ -246,18 +262,31 @@ func TestPreludeParseHeadersRoundTrip(t *testing.T) { cc: "bob@example.com", subject: "With CC", }, + { + name: "with bcc and from", + to: "alice@example.com", + bcc: "secret@example.com", + from: "Me ", + subject: "With hidden recipients", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - prelude := Prelude(tt.to, tt.cc, tt.subject, "") - gotTo, gotCC, _, gotSubject, _ := ParseHeaders(prelude) + prelude := Prelude(tt.to, tt.cc, tt.bcc, tt.from, tt.subject, "") + gotTo, gotCC, gotBCC, gotFrom, gotSubject, _ := ParseHeaders(prelude) if gotTo != tt.to { t.Errorf("round-trip to = %q, want %q", gotTo, tt.to) } if gotCC != tt.cc { t.Errorf("round-trip cc = %q, want %q", gotCC, tt.cc) } + if gotBCC != tt.bcc { + t.Errorf("round-trip bcc = %q, want %q", gotBCC, tt.bcc) + } + if gotFrom != tt.from { + t.Errorf("round-trip from = %q, want %q", gotFrom, tt.from) + } if gotSubject != tt.subject { t.Errorf("round-trip subject = %q, want %q", gotSubject, tt.subject) } diff --git a/internal/imap/client.go b/internal/imap/client.go index 7e9c438..8f0f84e 100644 --- a/internal/imap/client.go +++ b/internal/imap/client.go @@ -37,11 +37,12 @@ type Email struct { From string To string CC string // comma-separated CC addresses (may be empty) + BCC string // comma-separated BCC addresses (mainly useful for Drafts) ReplyTo string // Reply-To address if present (may be empty) Subject string Date time.Time Seen bool - Answered bool // \Answered flag — set when replied to from any client + Answered bool // \Answered flag — set when replied to from any client Folder string Size uint32 // RFC822 size in bytes HasAttachment bool // true if BODYSTRUCTURE contains an attachment part @@ -295,6 +296,13 @@ func (c *Client) FetchHeaders(ctx context.Context, folder string, n int) ([]Emai } e.CC = strings.Join(cc, ", ") } + if len(m.Envelope.Bcc) > 0 { + bcc := make([]string, 0, len(m.Envelope.Bcc)) + for _, a := range m.Envelope.Bcc { + bcc = append(bcc, a.Addr()) + } + e.BCC = strings.Join(bcc, ", ") + } if len(m.Envelope.ReplyTo) > 0 { e.ReplyTo = m.Envelope.ReplyTo[0].Addr() } @@ -671,6 +679,13 @@ func (c *Client) FetchHeadersByUID(ctx context.Context, folder string, uids []ui } e.CC = strings.Join(cc, ", ") } + if len(m.Envelope.Bcc) > 0 { + bcc := make([]string, 0, len(m.Envelope.Bcc)) + for _, a := range m.Envelope.Bcc { + bcc = append(bcc, a.Addr()) + } + e.BCC = strings.Join(bcc, ", ") + } } e.Size = uint32(m.RFC822Size) e.HasAttachment = hasAttachment(m.BodyStructure) diff --git a/internal/screener/screener.go b/internal/screener/screener.go index 5328d4e..d646035 100644 --- a/internal/screener/screener.go +++ b/internal/screener/screener.go @@ -15,12 +15,12 @@ import ( type Category int const ( - CategoryToScreen Category = iota // unknown — awaiting decision - CategoryInbox // approved sender - CategoryScreenedOut // blocked (known human/company) - CategoryFeed // newsletter / feed - CategoryPaperTrail // receipts / notifications - CategorySpam // actual spam — never needs review + CategoryToScreen Category = iota // unknown — awaiting decision + CategoryInbox // approved sender + CategoryScreenedOut // blocked (known human/company) + CategoryFeed // newsletter / feed + CategoryPaperTrail // receipts / notifications + CategorySpam // actual spam — never needs review ) func (c Category) String() string { @@ -59,6 +59,16 @@ type Screener struct { spam map[string]bool } +// Snapshot is a point-in-time copy of all screener list files and in-memory sets. +// It is used to roll back a failed screener operation. +type Snapshot struct { + ScreenedIn map[string]bool + ScreenedOut map[string]bool + Feed map[string]bool + PaperTrail map[string]bool + Spam map[string]bool +} + // New loads all lists from the paths in cfg. // Missing files are silently skipped (treated as empty). func New(cfg Config) (*Screener, error) { @@ -166,6 +176,62 @@ func (s *Screener) MarkPaperTrail(from string) error { return s.addToList(s.cfg.PaperTrail, s.paperTrail, from) } +func cloneSet(src map[string]bool) map[string]bool { + dst := make(map[string]bool, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + +// Snapshot captures the current screener state so a caller can roll back. +func (s *Screener) Snapshot() Snapshot { + return Snapshot{ + ScreenedIn: cloneSet(s.screenedIn), + ScreenedOut: cloneSet(s.screenedOut), + Feed: cloneSet(s.feed), + PaperTrail: cloneSet(s.paperTrail), + Spam: cloneSet(s.spam), + } +} + +func writeSet(path string, m map[string]bool) error { + lines := make([]string, 0, len(m)) + for addr := range m { + lines = append(lines, addr) + } + content := "" + if len(lines) > 0 { + content = strings.Join(lines, "\n") + "\n" + } + return os.WriteFile(path, []byte(content), 0600) +} + +// Restore rewrites all screener list files and in-memory sets from a snapshot. +func (s *Screener) Restore(snapshot Snapshot) error { + if err := writeSet(s.cfg.ScreenedIn, snapshot.ScreenedIn); err != nil { + return err + } + if err := writeSet(s.cfg.ScreenedOut, snapshot.ScreenedOut); err != nil { + return err + } + if err := writeSet(s.cfg.Feed, snapshot.Feed); err != nil { + return err + } + if err := writeSet(s.cfg.PaperTrail, snapshot.PaperTrail); err != nil { + return err + } + if err := writeSet(s.cfg.Spam, snapshot.Spam); err != nil { + return err + } + s.screenedIn = cloneSet(snapshot.ScreenedIn) + s.screenedOut = cloneSet(snapshot.ScreenedOut) + s.feed = cloneSet(snapshot.Feed) + s.paperTrail = cloneSet(snapshot.PaperTrail) + s.spam = cloneSet(snapshot.Spam) + return nil +} + // removeFromList deletes addr from the file and in-memory set if present. func (s *Screener) removeFromList(path string, m map[string]bool, from string) error { addr := normalise(from) diff --git a/internal/screener/screener_test.go b/internal/screener/screener_test.go index 86e1e2a..e01606b 100644 --- a/internal/screener/screener_test.go +++ b/internal/screener/screener_test.go @@ -342,6 +342,39 @@ func TestFileOperations(t *testing.T) { } } }) + + t.Run("Snapshot and Restore roll back mutations", func(t *testing.T) { + dir := t.TempDir() + cfg := makeCfg(dir) + + s, err := New(cfg) + if err != nil { + t.Fatal(err) + } + if err := s.Approve("undo@example.com"); err != nil { + t.Fatal(err) + } + snap := s.Snapshot() + if err := s.Block("undo@example.com"); err != nil { + t.Fatal(err) + } + if got := s.Classify("undo@example.com"); got != CategoryScreenedOut { + t.Fatalf("after Block got %v, want ScreenedOut", got) + } + if err := s.Restore(snap); err != nil { + t.Fatal(err) + } + if got := s.Classify("undo@example.com"); got != CategoryInbox { + t.Fatalf("after Restore got %v, want Inbox", got) + } + data, err := os.ReadFile(cfg.ScreenedIn) + if err != nil { + t.Fatal(err) + } + if string(data) != "undo@example.com\n" { + t.Fatalf("screened_in contents = %q, want restored entry", data) + } + }) } // --------------------------------------------------------------------------- diff --git a/internal/smtp/sender.go b/internal/smtp/sender.go index 2cd811e..a8c992b 100644 --- a/internal/smtp/sender.go +++ b/internal/smtp/sender.go @@ -199,6 +199,17 @@ func BuildMessage(from, to, cc, subject, markdownBody string, attachments []stri return buildMessage(from, to, cc, subject, markdownBody, htmlBody, attachments) } +// BuildDraftMessage constructs a raw MIME draft for IMAP APPEND. +// Unlike SMTP delivery, drafts should retain the Bcc header so the user's +// intent survives round-tripping through Drafts. +func BuildDraftMessage(from, to, cc, bcc, subject, markdownBody string, attachments []string) ([]byte, error) { + htmlBody, err := render.ToHTML(markdownBody) + if err != nil { + return nil, fmt.Errorf("markdown to html: %w", err) + } + return buildMessageWithBCC(from, to, cc, bcc, subject, markdownBody, htmlBody, attachments) +} + // inlineImage holds a local image path and its assigned Content-ID. type inlineImage struct { path string @@ -215,6 +226,10 @@ type inlineImage struct { // - images only → multipart/related > (multipart/alternative + inline images) // - images + files → multipart/mixed > (multipart/related > alt+images) + files func buildMessage(from, to, cc, subject, plainText, htmlBody string, attachments []string) ([]byte, error) { + return buildMessageWithBCC(from, to, cc, "", subject, plainText, htmlBody, attachments) +} + +func buildMessageWithBCC(from, to, cc, bcc, subject, plainText, htmlBody string, attachments []string) ([]byte, error) { // Find local image paths in htmlBody (), assign CIDs. var inlines []inlineImage processedHTML := imgSrcRe.ReplaceAllStringFunc(htmlBody, func(match string) string { @@ -246,6 +261,9 @@ func buildMessage(from, to, cc, subject, plainText, htmlBody string, attachments if cc != "" { hdr("Cc", cc) } + if bcc != "" { + hdr("Bcc", bcc) + } hdr("Subject", mime.QEncoding.Encode("utf-8", subject)) hdr("Date", time.Now().Format(time.RFC1123Z)) hdr("Message-ID", "<"+msgID+"@neomd>") diff --git a/internal/ui/cmdline.go b/internal/ui/cmdline.go index c8fdcad..2359c34 100644 --- a/internal/ui/cmdline.go +++ b/internal/ui/cmdline.go @@ -13,7 +13,7 @@ import ( // neomdCmd is a registered colon-command (like vim's :command). type neomdCmd struct { - name string // full name, e.g. "screen-all" + name string // full name, e.g. "screen-all" aliases []string // short forms accepted, e.g. ["sa", "screen-a"] desc string // run is called when the command is executed; m is the current model. @@ -32,6 +32,11 @@ func init() { aliases: []string{"s"}, desc: "screen currently loaded emails only (up to inbox_count)", run: func(m *Model) (tea.Model, tea.Cmd) { + if err := m.validateScreenerSafety(); err != nil { + m.status = err.Error() + m.isError = true + return m, nil + } moves := m.previewAutoScreen() if len(moves) == 0 { m.status = "Nothing to screen — all senders already classified." @@ -47,6 +52,11 @@ func init() { aliases: []string{"sa", "screen-a"}, desc: "fetch and screen EVERY inbox email, no limit (use after updating screener lists)", run: func(m *Model) (tea.Model, tea.Cmd) { + if err := m.validateScreenerSafety(); err != nil { + m.status = err.Error() + m.isError = true + return m, nil + } m.loading = true return m, m.deepScreenCmd() }, @@ -137,7 +147,7 @@ func init() { { name: "search", aliases: []string{"se"}, - desc: "IMAP search all emails in current folder (From + Subject)", + desc: "IMAP search all emails across all configured folders (From + Subject + To)", run: func(m *Model) (tea.Model, tea.Cmd) { m.imapSearchActive = true m.imapSearchText = "" @@ -182,11 +192,14 @@ func init() { m.isError = true return m, nil } - to, cc, bcc, subject, body := editor.ParseHeaders(string(raw)) + to, cc, bcc, from, subject, body := editor.ParseHeaders(string(raw)) // Pre-fill compose fields. m.compose.reset() m.presendFromI = 0 + if idx := m.matchFromAddress(from); idx >= 0 { + m.presendFromI = idx + } m.compose.to.SetValue(to) m.compose.cc.SetValue(cc) m.compose.bcc.SetValue(bcc) diff --git a/internal/ui/inbox.go b/internal/ui/inbox.go index f0741f0..9407bf6 100644 --- a/internal/ui/inbox.go +++ b/internal/ui/inbox.go @@ -17,6 +17,7 @@ type emailItem struct { email imap.Email index int // position in list (1-based) marked bool // selected for batch operation + displaySubj string // rendered subject (may include folder prefix in temporary views) threadPrefix string // tree chars e.g. "┌─>" for threaded display } @@ -33,7 +34,7 @@ type emailDelegate struct { draftFolder string // when active folder matches, show To instead of From } -func (d emailDelegate) Height() int { return 1 } +func (d emailDelegate) Height() int { return 1 } func (d emailDelegate) Spacing() int { return 0 } func (d emailDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } @@ -105,7 +106,11 @@ func (d emailDelegate) Render(w io.Writer, m list.Model, index int, item list.It sender = "→ " + e.email.To // show recipient in Drafts } from := truncate(cleanFrom(sender), fromMax) - subject := truncate(e.email.Subject, subjectMax) + subjectText := e.email.Subject + if e.displaySubj != "" { + subjectText = e.displaySubj + } + subject := truncate(subjectText, subjectMax) if isSelected { row := fmt.Sprintf("%s%s%s%s%s%s%-*s %-*s %s", @@ -232,14 +237,19 @@ func newInboxList(width, height int, sentFolder, draftFolder string) list.Model // setEmails replaces the list contents, preserving marked state. // It threads emails before display — grouped conversations appear together // with tree-drawing prefixes (┌─>) on reply rows. -func setEmails(l *list.Model, emails []imap.Email, marked map[uint32]bool) tea.Cmd { +func setEmails(l *list.Model, emails []imap.Email, marked map[uint32]bool, prefixFolders bool) tea.Cmd { threaded := threadEmails(emails) items := make([]list.Item, len(threaded)) for i, te := range threaded { + displaySubj := te.email.Subject + if prefixFolders { + displaySubj = "[" + te.email.Folder + "] " + displaySubj + } items[i] = emailItem{ email: te.email, index: i + 1, marked: marked[te.email.UID], + displaySubj: displaySubj, threadPrefix: te.threadPrefix, } } diff --git a/internal/ui/keys.go b/internal/ui/keys.go index cc68ec0..7577856 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -12,6 +12,7 @@ type HelpSection struct { var HelpSections = []HelpSection{ {"Navigation", [][2]string{ {"j / k", "move down / up"}, + {"d / u", "page down / up in inbox/help"}, {"gg", "jump to top"}, {"G", "jump to bottom"}, {"enter / l", "open email"}, @@ -30,6 +31,7 @@ var HelpSections = []HelpSection{ {"gk", "go to ToScreen"}, {"go", "go to ScreenedOut"}, {"gw", "go to Waiting"}, + {"gb", "go to Work (if configured)"}, {"gm", "go to Someday"}, {"gd", "go to Drafts"}, {"ge", "go to Everything — latest 50 emails across all folders"}, @@ -53,6 +55,7 @@ var HelpSections = []HelpSection{ {"Mt", "move to Trash"}, {"Mo", "move to ScreenedOut"}, {"Mw", "move to Waiting"}, + {"Mb", "move to Work (if configured)"}, {"Mm", "move to Someday"}, {"Mk", "move to ToScreen"}, }}, diff --git a/internal/ui/model.go b/internal/ui/model.go index 08f119b..6f1b1f9 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -8,6 +8,7 @@ import ( "path/filepath" "regexp" "sort" + "strconv" "strings" "sync/atomic" "time" @@ -122,9 +123,9 @@ type ( err error } editorDoneMsg struct { - to, cc, bcc, subject, body string - err error - aborted bool // true when file was unchanged (ZQ / :q!) + to, cc, bcc, from, subject, body string + err error + aborted bool // true when file was unchanged (ZQ / :q!) } ) @@ -169,6 +170,24 @@ func neomdTempDir() string { return dir } +func detectStartupNotice() string { + _, hasYazi := exec.LookPath("yazi") + home, _ := os.UserHomeDir() + customLua := filepath.Join(home, ".config", "nvim", "lua", "sspaeti", "custom.lua") + _, customLuaErr := os.Stat(customLua) + + switch { + case hasYazi != nil && customLuaErr != nil: + return "Optional inline a attachments in nvim are unavailable: install yazi and add the custom.lua integration. Pre-send 'a' still works." + case hasYazi != nil: + return "Optional inline a attachments in nvim are unavailable: install yazi. Pre-send 'a' still works." + case customLuaErr != nil: + return "Optional inline a attachments in nvim are not configured. Add the custom.lua integration if you want that workflow; pre-send 'a' still works." + default: + return "" + } +} + // backupFile holds a backup's full path and modification time. type backupFile struct { path string @@ -373,8 +392,9 @@ type pendingSendData struct { to, cc, bcc, subject, body string // replyToUID/replyToFolder track the original email when this is a reply, // so we can set \Answered after sending. Zero means not a reply. - replyToUID uint32 - replyToFolder string + replyToUID uint32 + replyToFolder string + replyToAccount string } // undoMove records one IMAP move so it can be reversed with u. @@ -433,8 +453,9 @@ type Model struct { presendFromI int // index into presendFroms() for the From field cycle // Status / error - status string - isError bool + status string + isError bool + startupNotice string // Auto-screen dry-run: populated by S, cleared by y/n pendingMoves []autoScreenMove @@ -457,8 +478,10 @@ type Model struct { // prevState is the state to return to when closing the help overlay prevState viewState - // helpSearch is the live filter string typed in the help overlay - helpSearch string + // helpSearch / helpScroll track the ? overlay state. + helpSearch string + helpSearchActive bool + helpScroll int // cmdMode / cmdText / cmdTabI implement vim-style ":" command line. cmdMode bool @@ -485,6 +508,9 @@ type Model struct { // pendingDeleteAll holds UIDs + folder awaiting y/n before permanent deletion. pendingDeleteAll *deleteAllReadyMsg + // pendingDiscard asks for y/n confirmation before dropping unsent compose state. + pendingDiscard bool + // folderCounts holds unseen message counts for watched folder tabs. // Keys are tab labels: "Inbox", "PaperTrail", "Waiting", "Scheduled". folderCounts map[string]int @@ -515,11 +541,12 @@ func New(cfg *config.Config, clients []*imap.Client, sc *screener.Screener) Mode cmdHistory: loadCmdHistory(config.HistoryPath()), cmdHistI: -1, // Note: Spam is intentionally excluded from tabs — use :go-spam to visit. - compose: compose, - spinner: sp, - markedUIDs: make(map[uint32]bool), - sortField: "date", - sortReverse: true, // newest first + compose: compose, + spinner: sp, + markedUIDs: make(map[uint32]bool), + startupNotice: detectStartupNotice(), + sortField: "date", + sortReverse: true, // newest first } } @@ -588,6 +615,25 @@ func (m Model) presendSMTPAccount() config.AccountConfig { return m.activeAccount() } +func (m Model) imapCliForAccount(accountName string) *imap.Client { + for i, a := range m.accounts { + if strings.EqualFold(a.Name, accountName) && i < len(m.clients) { + return m.clients[i] + } + } + return m.imapCli() +} + +func (m Model) presendIMAPClient() *imap.Client { + return m.imapCliForAccount(m.presendSMTPAccount().Name) +} + +func (m *Model) applyEditedFrom(from string) { + if idx := m.matchFromAddress(from); idx >= 0 { + m.presendFromI = idx + } +} + // imapCli returns the IMAP client for the active account. func (m Model) imapCli() *imap.Client { if m.accountI < len(m.clients) { @@ -610,6 +656,12 @@ func (m Model) Init() tea.Cmd { // activeFolder maps the active tab label to an IMAP mailbox name. func (m Model) activeFolder() string { + switch m.offTabFolder { + case "Drafts": + return m.cfg.Folders.Drafts + case "Spam": + return m.cfg.Folders.Spam + } switch m.folders[m.activeFolderI] { case "ToScreen": return m.cfg.Folders.ToScreen @@ -662,7 +714,7 @@ func (m Model) fetchBodyCmd(e *imap.Email) tea.Cmd { } } -func (m Model) sendEmailCmd(smtpAcct config.AccountConfig, from, to, cc, bcc, subject, body string, attachments []string, replyToUID uint32, replyToFolder string) tea.Cmd { +func (m Model) sendEmailCmd(smtpAcct config.AccountConfig, from, to, cc, bcc, subject, body string, attachments []string, replyToUID uint32, replyToFolder, replyToAccount string) tea.Cmd { h, p := splitAddr(smtpAcct.SMTP) cfg := smtp.Config{ Host: h, @@ -673,8 +725,9 @@ func (m Model) sendEmailCmd(smtpAcct config.AccountConfig, from, to, cc, bcc, su STARTTLS: smtpAcct.STARTTLS, TokenSource: m.tokenSourceFor(smtpAcct.Name), } - cli := m.imapCli() + cli := m.imapCliForAccount(smtpAcct.Name) sentFolder := m.cfg.Folders.Sent + replyCli := m.imapCliForAccount(replyToAccount) return func() tea.Msg { // Build raw MIME once — reused for both SMTP delivery and Sent copy. // BCC is intentionally excluded from headers but included in RCPT TO. @@ -692,7 +745,7 @@ func (m Model) sendEmailCmd(smtpAcct config.AccountConfig, from, to, cc, bcc, su } // Mark original email as \Answered (non-fatal). if replyToUID > 0 && replyToFolder != "" { - _ = cli.MarkAnswered(nil, replyToFolder, replyToUID) + _ = replyCli.MarkAnswered(nil, replyToFolder, replyToUID) } return sendDoneMsg{replyToUID: replyToUID, replyToFolder: replyToFolder} } @@ -756,6 +809,91 @@ func (m Model) targetEmails() []imap.Email { return nil } +func normalizedSender(from string) string { + return strings.ToLower(extractEmailAddr(from)) +} + +func writeAttachmentsTemp(files []imap.Attachment) ([]string, error) { + paths := make([]string, 0, len(files)) + for _, a := range files { + base := filepath.Base(a.Filename) + if base == "." || base == string(filepath.Separator) || base == "" { + base = "attachment" + } + f, err := os.CreateTemp(neomdTempDir(), "draft-"+base+"-*") + if err != nil { + return nil, err + } + if _, err := f.Write(a.Data); err != nil { + f.Close() + os.Remove(f.Name()) + return nil, err + } + if err := f.Close(); err != nil { + os.Remove(f.Name()) + return nil, err + } + paths = append(paths, f.Name()) + } + return paths, nil +} + +func (m Model) validateScreenerSafety() error { + dests := map[string]string{ + "ToScreen": m.cfg.Folders.ToScreen, + "ScreenedOut": m.cfg.Folders.ScreenedOut, + "Feed": m.cfg.Folders.Feed, + "PaperTrail": m.cfg.Folders.PaperTrail, + "Spam": m.cfg.Folders.Spam, + } + for name, folder := range dests { + if folder != "" && folder == m.cfg.Folders.Trash { + return fmt.Errorf("unsafe folder config: %s points to Trash (%s); refusing to screen until config is fixed", name, folder) + } + } + return nil +} + +func (m Model) inboxPageStep() int { + if m.height <= 8 { + return 10 + } + return m.height - 6 +} + +func (m Model) hasComposeDraft() bool { + if m.pendingSend != nil { + if strings.TrimSpace(m.pendingSend.to) != "" || + strings.TrimSpace(m.pendingSend.cc) != "" || + strings.TrimSpace(m.pendingSend.bcc) != "" || + strings.TrimSpace(m.pendingSend.subject) != "" || + strings.TrimSpace(m.pendingSend.body) != "" { + return true + } + } + if strings.TrimSpace(m.compose.to.Value()) != "" || + strings.TrimSpace(m.compose.cc.Value()) != "" || + strings.TrimSpace(m.compose.bcc.Value()) != "" || + strings.TrimSpace(m.compose.subject.Value()) != "" { + return true + } + return len(m.attachments) > 0 +} + +func (m *Model) beginDiscardConfirm() { + m.pendingDiscard = true + m.status = "Discard unsent message? · y discard, n keep editing" + m.isError = true +} + +func (m *Model) cancelDiscardConfirm() { + m.pendingDiscard = false + if m.status == "Discard unsent message? · y discard, n keep editing" { + m.status = "" + m.isError = false + } +} + // batchMoveCmd moves a slice of emails to dst, emitting batchDoneMsg. func (m Model) batchMoveCmd(emails []imap.Email, dst string) tea.Cmd { type mv struct { @@ -825,13 +963,54 @@ func (m Model) batchScreenerCmd(emails []imap.Email, action string) tea.Cmd { } bp := m.bulkProgress return func() tea.Msg { - for i, o := range ops { - // Move first, classify after — if move fails, screener file stays unchanged. - if o.dst != "" && o.dst != o.srcFolder { - if _, err := m.imapCli().MoveMessage(nil, o.srcFolder, o.uid, o.dst); err != nil { - return batchDoneMsg{err: fmt.Errorf("stopped after %d/%d: %w", i, len(ops), err)} + if err := m.validateScreenerSafety(); err != nil { + return batchDoneMsg{err: err} + } + expandedOps := ops + if len(emails) == 1 && len(m.markedUIDs) == 0 && emails[0].Folder == cfg.Folders.ToScreen { + sender := normalizedSender(emails[0].From) + uids, err := m.imapCli().SearchUIDs(nil, cfg.Folders.ToScreen) + if err != nil { + return batchDoneMsg{err: err} + } + expandedOps = nil + for start := 0; start < len(uids); start += 200 { + end := start + 200 + if end > len(uids) { + end = len(uids) + } + batch, err := m.imapCli().FetchHeadersByUID(nil, cfg.Folders.ToScreen, uids[start:end]) + if err != nil { + return batchDoneMsg{err: err} + } + for _, e := range batch { + if normalizedSender(e.From) == sender { + var dst string + switch action { + case "I": + dst = cfg.Folders.Inbox + case "O": + dst = cfg.Folders.ScreenedOut + case "F": + dst = cfg.Folders.Feed + case "P": + dst = cfg.Folders.PaperTrail + case "$": + dst = cfg.Folders.Spam + } + expandedOps = append(expandedOps, op{e.From, e.Folder, e.UID, dst}) + } } } + } + snapshot := sc.Snapshot() + seenSenders := make(map[string]bool) + for _, o := range expandedOps { + sender := normalizedSender(o.from) + if seenSenders[sender] { + continue + } + seenSenders[sender] = true var err error switch action { case "I": @@ -846,7 +1025,31 @@ func (m Model) batchScreenerCmd(emails []imap.Email, action string) tea.Cmd { err = sc.MarkSpam(o.from) } if err != nil { - return batchDoneMsg{err: fmt.Errorf("stopped after %d/%d: %w", i, len(ops), err)} + _ = sc.Restore(snapshot) + return batchDoneMsg{err: err} + } + } + var undos []undoMove + for i, o := range expandedOps { + if o.dst != "" && o.dst != o.srcFolder { + destUID, err := m.imapCli().MoveMessage(nil, o.srcFolder, o.uid, o.dst) + if err != nil { + var rollbackErrs []string + for j := len(undos) - 1; j >= 0; j-- { + u := undos[j] + if _, undoErr := m.imapCli().MoveMessage(nil, u.toFolder, u.uid, u.fromFolder); undoErr != nil { + rollbackErrs = append(rollbackErrs, fmt.Sprintf("%s:%d→%s (%v)", u.toFolder, u.uid, u.fromFolder, undoErr)) + } + } + if restoreErr := sc.Restore(snapshot); restoreErr != nil { + rollbackErrs = append(rollbackErrs, "screener restore: "+restoreErr.Error()) + } + if len(rollbackErrs) > 0 { + return batchDoneMsg{err: fmt.Errorf("stopped after %d/%d: %w (rollback failed: %s)", i, len(expandedOps), err, strings.Join(rollbackErrs, "; "))} + } + return batchDoneMsg{err: fmt.Errorf("stopped after %d/%d: %w", i, len(expandedOps), err)} + } + undos = append(undos, undoMove{uid: destUID, fromFolder: o.srcFolder, toFolder: o.dst}) } if bp != nil { bp.moved.Add(1) @@ -912,6 +1115,9 @@ func (m Model) batchToggleSeenCmd(emails []imap.Email) tea.Cmd { // lookups) and returns planned moves. emails must live at least as long as the // returned moves (pointers into the slice are stored). func (m Model) classifyForScreen(emails []imap.Email) []autoScreenMove { + if m.validateScreenerSafety() != nil { + return nil + } inboxFolder := m.cfg.Folders.Inbox var moves []autoScreenMove for i := range emails { @@ -1179,6 +1385,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.activeFolderI = z.folderIndex m.offTabFolder = "" + m.imapSearchText = "" m.loading = true return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) } @@ -1212,6 +1419,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.markedUIDs = make(map[uint32]bool) // clear marks on folder reload m.filterActive = false m.filterText = "" + if m.status == "" && m.startupNotice != "" { + m.status = m.startupNotice + m.startupNotice = "" + } sortCmd := m.sortEmails() // applies sort and sets list items // First-run welcome: show a brief intro popup. @@ -1228,6 +1439,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Skip when all screener lists are empty — otherwise every email would // be moved to ToScreen on first run, confusing new users. if msg.folder == m.cfg.Folders.Inbox && m.cfg.UI.AutoScreen() && !m.screener.IsEmpty() { + if err := m.validateScreenerSafety(); err != nil { + m.status = err.Error() + m.isError = true + return m, tea.Batch(sortCmd, m.fetchFolderCountsCmd()) + } if moves := m.previewAutoScreen(); len(moves) > 0 { m.loading = true m.bulkProgress = m.newBulkOp("Screening", len(moves)) @@ -1431,6 +1647,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) case deepScreenCountMsg: + if err := m.validateScreenerSafety(); err != nil { + m.loading = false + m.status = err.Error() + m.isError = true + return m, nil + } // Phase 1 done: we know how many emails exist. Show count and kick off phase 2. m.status = fmt.Sprintf("Screen-all: found %d emails — fetching headers in batches…", msg.total) return m, tea.Batch(m.spinner.Tick, m.deepScreenClassifyCmd(nil, msg.uids, msg.total)) @@ -1531,6 +1753,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(m.bgFetchInboxCmd(), m.scheduleBgSync()) case bgInboxFetchedMsg: + if err := m.validateScreenerSafety(); err != nil { + m.status = err.Error() + m.isError = true + return m, nil + } moves := m.classifyForScreen(msg.emails) if len(moves) == 0 { return m, nil @@ -1557,24 +1784,28 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case editorDoneMsg: if msg.err != nil { + m.attachments = nil m.status = msg.err.Error() m.isError = true m.state = stateInbox return m, nil } if msg.aborted { - m.status = "Aborted (no changes saved)." + m.attachments = nil + m.status = "Aborted (no changes saved). Use :recover to reopen the latest backup." m.state = stateInbox return m, nil } if strings.TrimSpace(msg.body) == "" { - m.status = "Cancelled (empty body)." + m.attachments = nil + m.status = "Cancelled (empty body). Use :recover if you want the latest backup." m.state = stateInbox return m, nil } // Strip editor header hints and extract [attach] lines. inlineAttach, cleanBody := extractInlineAttachments(stripPrelude(msg.body)) m.attachments = append(m.attachments, inlineAttach...) + m.applyEditedFrom(msg.from) // Go to pre-send review instead of sending immediately. m.pendingSend = &pendingSendData{ @@ -1585,8 +1816,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.openEmail != nil && strings.HasPrefix(strings.ToLower(msg.subject), "re:") { m.pendingSend.replyToUID = m.openEmail.UID m.pendingSend.replyToFolder = m.openEmail.Folder + m.pendingSend.replyToAccount = m.activeAccount().Name } m.state = statePresend + m.status = "" + m.isError = false return m, nil case attachPickDoneMsg: @@ -1603,6 +1837,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state = m.prevState } else { m.prevState = m.state + m.helpSearch = "" + m.helpSearchActive = false + m.helpScroll = 0 m.state = stateHelp } return m, nil @@ -1765,6 +2002,11 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, tea.Quit case "esc": + if m.filterText != "" { + m.filterActive = false + m.filterText = "" + return m, m.applyFilter() + } if m.imapSearchResults { m.imapSearchResults = false m.imapSearchText = "" @@ -1772,6 +2014,16 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.loading = true return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) } + if m.offTabFolder != "" { + m.offTabFolder = "" + // If we have a pending search query, restore search results instead of activeFolder + if m.imapSearchText != "" { + m.loading = true + return m, tea.Batch(m.spinner.Tick, m.imapSearchAllCmd(m.imapSearchText)) + } + m.loading = true + return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) + } // ── Chord prefixes ────────────────────────────────────────────── case "g": @@ -1866,6 +2118,11 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.folders[m.activeFolderI] != "Inbox" { break } + if err := m.validateScreenerSafety(); err != nil { + m.status = err.Error() + m.isError = true + return m, nil + } moves := m.previewAutoScreen() if len(moves) == 0 { m.status = "Nothing to screen — all senders already classified." @@ -1936,6 +2193,7 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.activeFolderI = (m.activeFolderI + 1) % len(m.folders) m.offTabFolder = "" m.imapSearchResults = false + m.imapSearchText = "" m.loading = true return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) @@ -1943,6 +2201,7 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.activeFolderI = (m.activeFolderI - 1 + len(m.folders)) % len(m.folders) m.offTabFolder = "" m.imapSearchResults = false + m.imapSearchText = "" m.loading = true return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) @@ -1950,6 +2209,24 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.inbox.Select(len(m.inbox.Items()) - 1) return m, nil + case "d": + next := m.inbox.Index() + m.inboxPageStep() + if max := len(m.inbox.Items()) - 1; next > max { + next = max + } + if next >= 0 { + m.inbox.Select(next) + } + return m, nil + + case "u": + prev := m.inbox.Index() - m.inboxPageStep() + if prev < 0 { + prev = 0 + } + m.inbox.Select(prev) + return m, nil + case "/": m.filterActive = true m.filterText = "" @@ -1973,7 +2250,10 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case "c": + m.attachments = nil m.state = stateCompose + m.status = "" + m.isError = false m.compose.reset() m.presendFromI = 0 return m, nil @@ -2105,6 +2385,15 @@ func saveCmdHistory(path string, history []string) { _ = os.WriteFile(path, []byte(content), 0600) } +func (m Model) shouldPrefixFolderInSubject() bool { + switch m.offTabFolder { + case "Search", "Everything", "Thread": + return true + default: + return false + } +} + // addCmdHistory prepends input to history (deduplicating) and caps at 5 entries. func addCmdHistory(history []string, input string) []string { // Remove existing occurrence of the same command (dedup) @@ -2128,7 +2417,7 @@ func addCmdHistory(history []string, input string) []string { // Call this whenever filterText changes. func (m *Model) applyFilter() tea.Cmd { if m.filterText == "" { - return setEmails(&m.inbox, m.emails, m.markedUIDs) + return setEmails(&m.inbox, m.emails, m.markedUIDs, m.shouldPrefixFolderInSubject()) } query := strings.ToLower(m.filterText) var filtered []imap.Email @@ -2138,7 +2427,7 @@ func (m *Model) applyFilter() tea.Cmd { filtered = append(filtered, e) } } - return setEmails(&m.inbox, filtered, m.markedUIDs) + return setEmails(&m.inbox, filtered, m.markedUIDs, m.shouldPrefixFolderInSubject()) } // handleChord dispatches two-key sequences (g, M, space). @@ -2176,12 +2465,14 @@ func (m Model) handleChord(prefix, key string) (tea.Model, tea.Cmd) { if key == "S" { // gS — go to Spam (not in tab rotation) m.loading = true m.offTabFolder = "Spam" + m.imapSearchText = "" m.status = "Spam folder — press R to reload, tab to leave" return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.cfg.Folders.Spam)) } if key == "d" { // gd — go to Drafts (not in tab rotation) m.loading = true m.offTabFolder = "Drafts" + m.imapSearchText = "" m.status = "Drafts folder — press R to reload, tab to leave" return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.cfg.Folders.Drafts)) } @@ -2210,6 +2501,7 @@ func (m Model) handleChord(prefix, key string) (tea.Model, tea.Cmd) { } m.activeFolderI = i m.offTabFolder = "" + m.imapSearchText = "" m.loading = true return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) } @@ -2230,10 +2522,13 @@ func (m Model) handleChord(prefix, key string) (tea.Model, tea.Cmd) { "t": m.cfg.Folders.Trash, "o": m.cfg.Folders.ScreenedOut, "w": m.cfg.Folders.Waiting, - "b": m.cfg.Folders.Work, "m": m.cfg.Folders.Someday, "k": m.cfg.Folders.ToScreen, } + // Only add Work folder if configured + if m.cfg.Folders.Work != "" { + dstMap["b"] = m.cfg.Folders.Work + } if dst, ok := dstMap[key]; ok { m.loading = true m.bulkProgress = m.newBulkOp("Moving", len(targets)) @@ -2406,12 +2701,23 @@ func (m Model) openInBrowser() (tea.Model, tea.Cmd) { if a.ContentID == "" || len(a.Data) == 0 { continue } - imgPath := filepath.Join(neomdTempDir(), "cid-"+a.ContentID+"-"+a.Filename) + // Sanitize ContentID and Filename to prevent path traversal attacks + safeCID := strings.ReplaceAll(a.ContentID, string(os.PathSeparator), "_") + safeCID = strings.ReplaceAll(safeCID, "..", "_") + safeName := filepath.Base(a.Filename) + + imgPath := filepath.Join(neomdTempDir(), "cid-"+safeCID+"-"+safeName) + + // Verify the path is still under neomdTempDir() + if !strings.HasPrefix(imgPath, neomdTempDir()) { + continue + } + if err := os.WriteFile(imgPath, a.Data, 0600); err != nil { continue } tmpImages = append(tmpImages, imgPath) - // Replace cid:XYZ with file:///path (case-insensitive) + // Replace cid:XYZ with file:///path (case-sensitive match) htmlBody = strings.ReplaceAll(htmlBody, "cid:"+a.ContentID, "file://"+imgPath) } @@ -2612,22 +2918,40 @@ func (m Model) continueDraft() (tea.Model, tea.Cmd) { e := m.openEmail to := e.To cc := e.CC + bcc := e.BCC + from := e.From subject := e.Subject // Pre-fill compose fields so viewCompose shows them m.compose.reset() - m.presendFromI = 0 + if idx := m.matchFromAddress(from); idx >= 0 { + m.presendFromI = idx + } else { + m.presendFromI = 0 + } m.compose.to.SetValue(to) m.compose.cc.SetValue(cc) + m.compose.bcc.SetValue(bcc) m.compose.subject.SetValue(subject) - if cc != "" { + if cc != "" || bcc != "" { m.compose.extraVisible = true } m.compose.step = 3 // jump past header steps to subject-done state + if len(m.openAttachments) > 0 { + paths, err := writeAttachmentsTemp(m.openAttachments) + if err != nil { + m.status = "continueDraft attachments: " + err.Error() + m.isError = true + return m, nil + } + m.attachments = paths + } else { + m.attachments = nil + } // Build temp file with prelude + existing body. // No signature — the draft body already contains it from the first compose. - prelude := editor.Prelude(to, cc, subject, "") + prelude := editor.Prelude(to, cc, bcc, m.presendFrom(), subject, "") body := m.openBody f, err := os.CreateTemp(neomdTempDir(), "neomd-*.md") @@ -2647,6 +2971,8 @@ func (m Model) continueDraft() (tea.Model, tea.Cmd) { cmd := exec.Command(editorBin, tmpPath) draftBackups := m.cfg.UI.DraftBackups() m.state = stateCompose + m.status = "" + m.isError = false return m, tea.ExecProcess(cmd, func(execErr error) tea.Msg { backupDraft(tmpPath, draftBackups) defer os.Remove(tmpPath) @@ -2660,25 +2986,53 @@ func (m Model) continueDraft() (tea.Model, tea.Cmd) { if string(raw) == prelude+body { return editorDoneMsg{aborted: true} } - pto, pcc, _, psubject, _ := editor.ParseHeaders(string(raw)) + pto, pcc, pbcc, pfrom, psubject, _ := editor.ParseHeaders(string(raw)) if pto == "" { pto = to } if pcc == "" { pcc = cc } + if pbcc == "" { + pbcc = bcc + } + if pfrom == "" { + pfrom = m.presendFrom() + } if psubject == "" { psubject = subject } - return editorDoneMsg{to: pto, cc: pcc, bcc: "", subject: psubject, body: string(raw)} + return editorDoneMsg{to: pto, cc: pcc, bcc: pbcc, from: pfrom, subject: psubject, body: string(raw)} }) } func (m Model) updateCompose(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.pendingDiscard { + switch msg.String() { + case "y": + m.pendingDiscard = false + m.attachments = nil + m.pendingSend = nil + m.state = stateInbox + m.status = "Discarded. Use :recover to reopen the latest backup if needed." + m.isError = false + return m, nil + case "n", "esc": + m.cancelDiscardConfirm() + return m, nil + default: + return m, nil + } + } + switch msg.String() { case "esc": - m.attachments = nil + if m.hasComposeDraft() { + m.beginDiscardConfirm() + return m, nil + } m.state = stateInbox + m.status = "Cancelled." return m, nil case "ctrl+t": return m.launchAttachPickerCmd() @@ -2690,11 +3044,17 @@ func (m Model) updateCompose(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "ctrl+f": froms := m.presendFroms() - if len(froms) > 1 { - m.presendFromI = (m.presendFromI + 1) % len(froms) + if len(froms) <= 1 { + m.status = "Only one From address configured. Add another account or [[senders]] alias to cycle." + return m, nil } + m.presendFromI = (m.presendFromI + 1) % len(froms) return m, nil } + if m.status != "" { + m.status = "" + m.isError = false + } var cmd tea.Cmd var launch bool @@ -2713,6 +3073,23 @@ func (m Model) updatePresend(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.state = stateInbox return m, nil } + if m.pendingDiscard { + switch msg.String() { + case "y": + m.pendingDiscard = false + m.attachments = nil + m.pendingSend = nil + m.state = stateInbox + m.status = "Discarded. Use :recover to reopen the latest backup if needed." + m.isError = false + return m, nil + case "n", "esc": + m.cancelDiscardConfirm() + return m, nil + default: + return m, nil + } + } switch msg.String() { case "enter": m.loading = true @@ -2723,12 +3100,14 @@ func (m Model) updatePresend(msg tea.KeyMsg) (tea.Model, tea.Cmd) { replyUID, replyFolder := ps.replyToUID, ps.replyToFolder m.attachments = nil m.pendingSend = nil - return m, tea.Batch(m.spinner.Tick, m.sendEmailCmd(smtpAcct, from, ps.to, ps.cc, ps.bcc, ps.subject, ps.body, attachments, replyUID, replyFolder)) + 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)) case "ctrl+f": froms := m.presendFroms() - if len(froms) > 1 { - m.presendFromI = (m.presendFromI + 1) % len(froms) + if len(froms) <= 1 { + m.status = "Only one From address configured. Add another account or [[senders]] alias to cycle." + return m, nil } + m.presendFromI = (m.presendFromI + 1) % len(froms) return m, nil case "a": return m.launchAttachPickerCmd() @@ -2754,7 +3133,7 @@ func (m Model) updatePresend(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.launchSpellCheckCmd(ps) case "d": // Save to Drafts without sending. - return m, m.saveDraftCmd(m.presendFrom(), ps.to, ps.cc, ps.subject, ps.body, m.attachments) + return m, m.saveDraftCmd(m.presendIMAPClient(), m.presendFrom(), ps.to, ps.cc, ps.bcc, ps.subject, ps.body, m.attachments) case "ctrl+b": // Toggle CC/BCC fields — show input prompts to add/edit them. m.compose.extraVisible = !m.compose.extraVisible @@ -2768,22 +3147,18 @@ func (m Model) updatePresend(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil case "x": - // Discard the email entirely — clear everything and go back to inbox. - m.attachments = nil - m.pendingSend = nil - m.state = stateInbox - m.status = "Discarded." - m.isError = false + m.beginDiscardConfirm() return m, nil case "p": return m.previewInBrowser() case "esc": - m.attachments = nil - m.pendingSend = nil - m.state = stateInbox - m.status = "Cancelled." + m.beginDiscardConfirm() return m, nil } + if m.status != "" { + m.status = "" + m.isError = false + } return m, nil } @@ -2791,7 +3166,7 @@ func (m Model) updatePresend(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // enabled and the cursor positioned on the first misspelled word. // On return, the (possibly corrected) body replaces the pre-send body. func (m Model) launchSpellCheckCmd(ps *pendingSendData) (tea.Model, tea.Cmd) { - prelude := editor.Prelude(ps.to, ps.cc, ps.subject, "") + prelude := editor.Prelude(ps.to, ps.cc, ps.bcc, m.presendFrom(), ps.subject, "") content := prelude + ps.body f, err := os.CreateTemp(neomdTempDir(), "neomd-*.md") @@ -2822,7 +3197,7 @@ func (m Model) launchSpellCheckCmd(ps *pendingSendData) (tea.Model, tea.Cmd) { if readErr != nil { return editorDoneMsg{err: readErr} } - pto, pcc, pbcc, psubject, _ := editor.ParseHeaders(string(raw)) + pto, pcc, pbcc, pfrom, psubject, _ := editor.ParseHeaders(string(raw)) if pto == "" { pto = ps.to } @@ -2832,10 +3207,13 @@ func (m Model) launchSpellCheckCmd(ps *pendingSendData) (tea.Model, tea.Cmd) { if pbcc == "" { pbcc = ps.bcc } + if pfrom == "" { + pfrom = m.presendFrom() + } if psubject == "" { psubject = ps.subject } - return editorDoneMsg{to: pto, cc: pcc, bcc: pbcc, subject: psubject, body: string(raw)} + return editorDoneMsg{to: pto, cc: pcc, bcc: pbcc, from: pfrom, subject: psubject, body: string(raw)} }) } @@ -2886,15 +3264,14 @@ func (m Model) previewInBrowser() (tea.Model, tea.Cmd) { } } -func (m Model) saveDraftCmd(from, to, cc, subject, body string, attachments []string) tea.Cmd { - cli := m.imapCli() +func (m Model) saveDraftCmd(imapCli *imap.Client, from, to, cc, bcc, subject, body string, attachments []string) tea.Cmd { folder := m.cfg.Folders.Drafts return func() tea.Msg { - raw, err := smtp.BuildMessage(from, to, cc, subject, body, attachments) + raw, err := smtp.BuildDraftMessage(from, to, cc, bcc, subject, body, attachments) if err != nil { return saveDraftDoneMsg{err: err} } - err = cli.SaveDraft(nil, folder, raw) + err = imapCli.SaveDraft(nil, folder, raw) return saveDraftDoneMsg{err: err} } } @@ -2904,7 +3281,7 @@ func (m Model) launchEditorCmd() (tea.Model, tea.Cmd) { cc := m.compose.cc.Value() bcc := m.compose.bcc.Value() subject := m.compose.subject.Value() - prelude := editor.Prelude(to, cc, subject, m.cfg.UI.Signature) + prelude := editor.Prelude(to, cc, bcc, m.presendFrom(), subject, m.cfg.UI.Signature) // Write temp file f, err := os.CreateTemp(neomdTempDir(), "neomd-*.md") @@ -2938,7 +3315,7 @@ func (m Model) launchEditorCmd() (tea.Model, tea.Cmd) { if string(raw) == prelude { return editorDoneMsg{aborted: true} } - pto, pcc, pbcc, psubject, _ := editor.ParseHeaders(string(raw)) + pto, pcc, pbcc, pfrom, psubject, _ := editor.ParseHeaders(string(raw)) if pto == "" { pto = to } @@ -2948,10 +3325,13 @@ func (m Model) launchEditorCmd() (tea.Model, tea.Cmd) { if pbcc == "" { pbcc = bcc } + if pfrom == "" { + pfrom = m.presendFrom() + } if psubject == "" { psubject = subject } - return editorDoneMsg{to: pto, cc: pcc, bcc: pbcc, subject: psubject, body: string(raw)} + return editorDoneMsg{to: pto, cc: pcc, bcc: pbcc, from: pfrom, subject: psubject, body: string(raw)} }) } @@ -2959,7 +3339,7 @@ func (m Model) launchEditorCmd() (tea.Model, tea.Cmd) { // the pre-send screen). The prelude is built from the provided headers (no // signature — it is already in the body from the first compose). func (m Model) launchEditorWithBodyCmd(to, cc, bcc, subject, body string) (tea.Model, tea.Cmd) { - prelude := editor.Prelude(to, cc, subject, "") + prelude := editor.Prelude(to, cc, bcc, m.presendFrom(), subject, "") content := prelude + body f, err := os.CreateTemp(neomdTempDir(), "neomd-*.md") @@ -2993,7 +3373,7 @@ func (m Model) launchEditorWithBodyCmd(to, cc, bcc, subject, body string) (tea.M if string(raw) == content { return editorDoneMsg{aborted: true} } - pto, pcc, pbcc, psubject, _ := editor.ParseHeaders(string(raw)) + pto, pcc, pbcc, pfrom, psubject, _ := editor.ParseHeaders(string(raw)) if pto == "" { pto = to } @@ -3003,10 +3383,13 @@ func (m Model) launchEditorWithBodyCmd(to, cc, bcc, subject, body string) (tea.M if pbcc == "" { pbcc = bcc } + if pfrom == "" { + pfrom = m.presendFrom() + } if psubject == "" { psubject = subject } - return editorDoneMsg{to: pto, cc: pcc, bcc: pbcc, subject: psubject, body: string(raw)} + return editorDoneMsg{to: pto, cc: pcc, bcc: pbcc, from: pfrom, subject: psubject, body: string(raw)} }) } @@ -3096,7 +3479,10 @@ func (m Model) launchForwardCmd() (tea.Model, tea.Cmd) { if string(raw) == prelude { return editorDoneMsg{aborted: true} } - pto, _, _, psubject, _ := editor.ParseHeaders(string(raw)) + pto, _, _, pfrom, psubject, _ := editor.ParseHeaders(string(raw)) + if pfrom == "" { + pfrom = m.presendFrom() + } if psubject == "" { if !strings.HasPrefix(strings.ToLower(subject), "fwd:") { psubject = "Fwd: " + subject @@ -3104,7 +3490,7 @@ func (m Model) launchForwardCmd() (tea.Model, tea.Cmd) { psubject = subject } } - return editorDoneMsg{to: pto, cc: "", bcc: "", subject: psubject, body: string(raw)} + return editorDoneMsg{to: pto, cc: "", bcc: "", from: pfrom, subject: psubject, body: string(raw)} }) } @@ -3186,17 +3572,20 @@ func (m Model) launchReplyWithCC(extraCC string, replyAll bool) (tea.Model, tea. if string(raw) == prelude { return editorDoneMsg{aborted: true} } - pto, pcc, _, psubject, _ := editor.ParseHeaders(string(raw)) + pto, pcc, _, pfrom, psubject, _ := editor.ParseHeaders(string(raw)) if pto == "" { pto = to } if pcc == "" { pcc = cc } + if pfrom == "" { + pfrom = m.presendFrom() + } if psubject == "" { psubject = subject } - return editorDoneMsg{to: pto, cc: pcc, bcc: "", subject: psubject, body: string(raw)} + return editorDoneMsg{to: pto, cc: pcc, bcc: "", from: pfrom, subject: psubject, body: string(raw)} }) } @@ -3219,6 +3608,19 @@ func (m Model) matchFromIndex(toField, ccField string) int { return -1 } +func (m Model) matchFromAddress(from string) int { + target := strings.ToLower(extractEmailAddr(from)) + if target == "" { + return -1 + } + for i, candidate := range m.presendFroms() { + if strings.ToLower(extractEmailAddr(candidate)) == target { + return i + } + } + return -1 +} + // extractEmailAddr returns the bare email address from "Name " or "addr". // mergeAutoBCC appends autoBCC to the existing bcc field, deduped by email // address. Returns bcc unchanged when autoBCC is empty or already present. @@ -3388,8 +3790,11 @@ func (m Model) viewPresend() string { } b.WriteString("\n") } - - 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")) + if m.status != "" { + b.WriteString(statusBar(m.status, m.isError)) + } else { + 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")) + } return b.String() } @@ -3445,6 +3850,9 @@ func (m Model) viewInbox() string { if len(m.accounts) > 1 { help += styleHelp.Render(" · ctrl+a switch account") } + if len(m.emails) > 0 { + help += styleDate.Render(fmt.Sprintf(" │ %d loaded", len(m.emails))) + } b.WriteString(help) } return b.String() @@ -3483,41 +3891,76 @@ func (m Model) viewCompose() string { } b.WriteString("\n") } - b.WriteString(composeHelp(int(m.compose.step), len(m.presendFroms()) > 1)) + if m.status != "" { + b.WriteString(statusBar(m.status, m.isError)) + } else { + b.WriteString(composeHelp(int(m.compose.step), len(m.presendFroms()) > 1)) + } return b.String() } func (m Model) updateHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "esc": - if m.helpSearch != "" { - m.helpSearch = "" // first esc clears filter + if m.helpSearchActive { + if m.helpSearch != "" { + m.helpSearch = "" + m.helpScroll = 0 + } else { + m.helpSearchActive = false + } + } else if m.helpSearch != "" { + m.helpSearch = "" + m.helpScroll = 0 } else { m.state = m.prevState } + case "enter": + if m.helpSearchActive { + m.helpSearchActive = false + } case "q": - if m.helpSearch == "" { + if !m.helpSearchActive { m.state = m.prevState } else { m.helpSearch += "q" } - case "backspace": - if len(m.helpSearch) > 0 { - m.helpSearch = m.helpSearch[:len([]rune(m.helpSearch))-1] + case "backspace", "ctrl+h": + if m.helpSearchActive && len(m.helpSearch) > 0 { + runes := []rune(m.helpSearch) + m.helpSearch = string(runes[:len(runes)-1]) + m.helpScroll = 0 } case "/": - // already in search mode — "/" is just a printable char if search active - if m.helpSearch == "" { - // start typing to search; "/" itself doesn't appear - } else { + if m.helpSearchActive { m.helpSearch += "/" + m.helpScroll = 0 + } else { + m.helpSearchActive = true + } + case "j", "down": + if !m.helpSearchActive { + m.helpScroll++ + } + case "k", "up": + if !m.helpSearchActive && m.helpScroll > 0 { + m.helpScroll-- + } + case "d", "ctrl+d": + if !m.helpSearchActive { + m.helpScroll += m.helpPageSize() + } + case "u", "ctrl+u": + if !m.helpSearchActive { + m.helpScroll -= m.helpPageSize() } default: - // printable single character: append to search - if len(msg.String()) == 1 { + if m.helpSearchActive && len(msg.String()) == 1 { m.helpSearch += msg.String() + m.helpScroll = 0 } } + m.clampHelpScroll() return m, nil } @@ -3540,22 +3983,25 @@ func (m Model) viewWelcome() string { title.Render("Quick start") + "\n" + key.Render(" j/k") + " navigate " + key.Render("enter") + " open email\n" + key.Render(" c") + " compose " + key.Render("r") + " reply\n" + - key.Render(" f") + " forward " + key.Render("R") + " reply-all\n" + + key.Render(" f") + " forward " + key.Render("ctrl+r") + " reply-all\n" + key.Render(" ]") + " / " + key.Render("[") + " next/prev tab " + key.Render("?") + " all keys\n\n" + title.Render("How the Screener works") + "\n" + "Your screener lists are empty, so " + warn.Render("auto-screening") + "\n" + warn.Render("is paused") + " until you classify your first senders.\n\n" + title.Render("Getting started") + "\n" + - "1. Go to " + key.Render("ToScreen") + " tab (" + key.Render("gk") + " or " + key.Render("Tab") + " or click)\n" + + "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" + "2. Screen each sender:\n" + key.Render(" I") + " screen " + title.Render("in") + " " + dim.Render("sender stays in Inbox forever") + "\n" + key.Render(" O") + " screen " + title.Render("out") + " " + dim.Render("sender never reaches Inbox again") + "\n" + key.Render(" F") + " feed " + dim.Render("newsletters go to Feed tab") + "\n" + key.Render(" P") + " papertrail " + dim.Render("receipts go to PaperTrail tab") + "\n" + - "3. Use " + key.Render("m") + " to mark multiple, then " + key.Render("I") + " to batch-approve\n\n" + + "3. Use " + key.Render("m") + " to mark multiple, then " + key.Render("I") + " to batch-approve\n" + + "4. Normal loads only screen the newest " + key.Render(strconv.Itoa(m.cfg.UI.InboxCount)) + " Inbox emails\n" + + "5. Use " + key.Render(":screen-all") + " for the full Inbox on the server " + dim.Render("(slower; mailbox-wide)") + "\n\n" + dim.Render("Once classified, senders are remembered forever.") + "\n" + dim.Render("New emails auto-sort on every load. You choose") + "\n" + dim.Render("who lands in your inbox. Bye-bye spam.") + "\n\n" + + dim.Render("Inline a attachments in nvim require custom.lua + yazi.") + "\n" + dim.Render("Disable auto-screen: auto_screen_on_load = false") + "\n" + dim.Render("Diagnostics: :debug All keys: ?") + "\n\n" + dim.Render("Press any key to continue.") @@ -3594,8 +4040,7 @@ func (m Model) viewHelp() string { filter := strings.ToLower(m.helpSearch) - var b strings.Builder - b.WriteString(heading + "\n" + sep + "\n") + lines := []string{heading, sep} for _, sec := range HelpSections { var matched [][2]string for _, row := range sec.Rows { @@ -3606,23 +4051,91 @@ func (m Model) viewHelp() string { if len(matched) == 0 { continue } - b.WriteString("\n" + titleStyle.Render(" "+sec.Title) + "\n") + lines = append(lines, "", titleStyle.Render(" "+sec.Title)) for _, row := range matched { - b.WriteString(" " + keyStyle.Render(row[0]) + descStyle.Render(row[1]) + "\n") + lines = append(lines, " "+keyStyle.Render(row[0])+descStyle.Render(row[1])) } } - // Search bar var searchLine string - if filter != "" { - searchLine = matchStyle.Render(" /"+m.helpSearch) + styleHelp.Render(" · esc to clear") + if m.helpSearchActive { + searchLine = matchStyle.Render(" /"+m.helpSearch+"█") + styleHelp.Render(" · enter done · esc clear") + } else if filter != "" { + searchLine = matchStyle.Render(" /"+m.helpSearch) + styleHelp.Render(" · j/k scroll · / edit filter · esc clear") } else { - searchLine = styleHelp.Render(" type to filter · ? or q to close") + searchLine = styleHelp.Render(" j/k scroll · d/u page · / filter · ? or q close") + } + + contentHeight := m.height - 1 + if contentHeight < 1 { + contentHeight = len(lines) + } + start := m.helpScroll + if start < 0 { + start = 0 } - b.WriteString("\n" + searchLine) + maxStart := len(lines) - contentHeight + if maxStart < 0 { + maxStart = 0 + } + if start > maxStart { + start = maxStart + } + end := start + contentHeight + if end > len(lines) { + end = len(lines) + } + + var b strings.Builder + for _, line := range lines[start:end] { + b.WriteString(line + "\n") + } + b.WriteString(searchLine) return b.String() } +func (m Model) helpPageSize() int { + if m.height <= 8 { + return 1 + } + return (m.height - 4) / 2 +} + +func (m Model) helpContentLineCount() int { + filter := strings.ToLower(m.helpSearch) + count := 2 + for _, sec := range HelpSections { + matched := 0 + for _, row := range sec.Rows { + if filter == "" || strings.Contains(strings.ToLower(row[0]), filter) || strings.Contains(strings.ToLower(row[1]), filter) { + matched++ + } + } + if matched == 0 { + continue + } + count += 2 + matched + } + return count +} + +func (m *Model) clampHelpScroll() { + if m.helpScroll < 0 { + m.helpScroll = 0 + } + contentHeight := m.height - 1 + if contentHeight < 1 { + contentHeight = 1 + } + maxScroll := m.helpContentLineCount() - contentHeight + if maxScroll < 0 { + maxScroll = 0 + } + if m.helpScroll > maxScroll { + m.helpScroll = maxScroll + } +} + // ── Helpers ─────────────────────────────────────────────────────────────── func splitAddr(addr string) (host, port string) { diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 7707c80..51021b1 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -1,8 +1,13 @@ package ui import ( + "reflect" "strings" "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/sspaeti/neomd/internal/config" + "github.com/sspaeti/neomd/internal/imap" ) func TestMaskEmail(t *testing.T) { @@ -57,3 +62,234 @@ func TestURLSchemeValidation(t *testing.T) { }) } } + +func TestMergeAutoBCC(t *testing.T) { + tests := []struct { + name string + bcc string + autoBCC string + want string + }{ + { + name: "append when empty", + bcc: "", + autoBCC: "archive@example.com", + want: "archive@example.com", + }, + { + name: "append when distinct", + bcc: "team@example.com", + autoBCC: "archive@example.com", + want: "team@example.com, archive@example.com", + }, + { + name: "dedupe bare and named address", + bcc: "Archive ", + autoBCC: "archive@example.com", + want: "Archive ", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := mergeAutoBCC(tt.bcc, tt.autoBCC); got != tt.want { + t.Fatalf("mergeAutoBCC(%q, %q) = %q, want %q", tt.bcc, tt.autoBCC, got, tt.want) + } + }) + } +} + +func TestCollectRcptTo(t *testing.T) { + got := collectRcptTo( + "Alice , bob@example.com", + "bob@example.com, Carol ", + "alice@example.com, dave@example.com", + ) + want := []string{"alice@example.com", "bob@example.com", "carol@example.com", "dave@example.com"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("collectRcptTo() = %#v, want %#v", got, want) + } +} + +func TestPresendSMTPAccount(t *testing.T) { + cfg := &config.Config{ + Accounts: []config.AccountConfig{ + {Name: "Personal", From: "me@example.com"}, + {Name: "Work", From: "me@work.example"}, + }, + Senders: []config.SenderConfig{ + {Name: "Support", From: "support@example.com", Account: "Work"}, + }, + } + m := Model{ + cfg: cfg, + accounts: cfg.ActiveAccounts(), + accountI: 0, + } + + t.Run("selected account uses its own SMTP account", func(t *testing.T) { + m.presendFromI = 1 + if got := m.presendSMTPAccount().Name; got != "Work" { + t.Fatalf("presendSMTPAccount() = %q, want %q", got, "Work") + } + }) + + t.Run("sender alias resolves to referenced account", func(t *testing.T) { + m.presendFromI = 2 + if got := m.presendSMTPAccount().Name; got != "Work" { + t.Fatalf("presendSMTPAccount() = %q, want %q", got, "Work") + } + }) +} + +func TestMatchFromAddress(t *testing.T) { + cfg := &config.Config{ + Accounts: []config.AccountConfig{ + {Name: "Personal", From: "Me "}, + }, + Senders: []config.SenderConfig{ + {Name: "Work", From: "Me "}, + }, + } + m := Model{cfg: cfg, accounts: cfg.ActiveAccounts()} + if got := m.matchFromAddress("me@work.example"); got != 1 { + t.Fatalf("matchFromAddress() = %d, want 1", got) + } +} + +func TestActiveFolderUsesOffTabFolder(t *testing.T) { + m := Model{ + cfg: &config.Config{ + Folders: config.FoldersConfig{ + Inbox: "INBOX", + Drafts: "Drafts", + Spam: "Spam", + }, + }, + folders: []string{"Inbox"}, + activeFolderI: 0, + } + + m.offTabFolder = "Drafts" + if got := m.activeFolder(); got != "Drafts" { + t.Fatalf("activeFolder() with Drafts off-tab = %q, want %q", got, "Drafts") + } + + m.offTabFolder = "Spam" + if got := m.activeFolder(); got != "Spam" { + t.Fatalf("activeFolder() with Spam off-tab = %q, want %q", got, "Spam") + } +} + +func TestUpdateInboxEscClearsCommittedFilter(t *testing.T) { + m := Model{ + filterText: "invoice", + inbox: newInboxList(80, 20, "", ""), + folders: []string{"Inbox"}, + cfg: &config.Config{ + Folders: config.FoldersConfig{Inbox: "INBOX"}, + }, + } + + next, _ := m.updateInbox(tea.KeyMsg{Type: tea.KeyEsc}) + got := next.(Model) + if got.filterText != "" { + t.Fatalf("filterText = %q, want empty", got.filterText) + } + if got.filterActive { + t.Fatal("filterActive should be false after esc") + } +} + +func TestValidateScreenerSafetyRejectsTrashDestination(t *testing.T) { + m := Model{ + cfg: &config.Config{ + Folders: config.FoldersConfig{ + Trash: "Trash", + ScreenedOut: "Trash", + }, + }, + } + + err := m.validateScreenerSafety() + if err == nil { + t.Fatal("expected validateScreenerSafety to fail when ScreenedOut points to Trash") + } +} + +func TestUpdateComposeEscRequestsDiscardConfirmation(t *testing.T) { + m := Model{ + compose: newComposeModel(), + } + m.compose.to.SetValue("alice@example.com") + m.state = stateCompose + + next, _ := m.updateCompose(tea.KeyMsg{Type: tea.KeyEsc}) + got := next.(Model) + if !got.pendingDiscard { + t.Fatal("expected pendingDiscard after esc with unsent compose data") + } + if got.state != stateCompose { + t.Fatalf("state = %v, want compose", got.state) + } + if got.status == "" { + t.Fatal("expected discard confirmation status") + } +} + +func TestUpdateComposeDiscardConfirmationYClearsState(t *testing.T) { + m := Model{ + compose: newComposeModel(), + attachments: []string{"/tmp/file.txt"}, + pendingDiscard: true, + state: stateCompose, + } + m.compose.to.SetValue("alice@example.com") + + next, _ := m.updateCompose(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}) + got := next.(Model) + if got.pendingDiscard { + t.Fatal("pendingDiscard should be cleared after confirming discard") + } + if got.state != stateInbox { + t.Fatalf("state = %v, want inbox", got.state) + } + if len(got.attachments) != 0 { + t.Fatalf("attachments = %#v, want cleared", got.attachments) + } +} + +func TestUpdatePresendEscRequestsDiscardConfirmation(t *testing.T) { + m := Model{ + pendingSend: &pendingSendData{ + to: "alice@example.com", + subject: "hello", + body: "body", + }, + state: statePresend, + } + + next, _ := m.updatePresend(tea.KeyMsg{Type: tea.KeyEsc}) + got := next.(Model) + if !got.pendingDiscard { + t.Fatal("expected pendingDiscard after esc in pre-send") + } + if got.state != statePresend { + t.Fatalf("state = %v, want pre-send", got.state) + } +} + +func TestHandleEverythingResultKeepsRealSubject(t *testing.T) { + m := Model{ + inbox: newInboxList(80, 20, "", ""), + } + msg := everythingResultMsg{ + emails: []imap.Email{{UID: 1, Folder: "Sent", Subject: "Quarterly update"}}, + } + + next, _ := m.handleEverythingResult(msg) + got := next.(*Model) + if got.emails[0].Subject != "Quarterly update" { + t.Fatalf("subject = %q, want unchanged real subject", got.emails[0].Subject) + } +} diff --git a/internal/ui/reader.go b/internal/ui/reader.go index e30932f..0ce10b4 100644 --- a/internal/ui/reader.go +++ b/internal/ui/reader.go @@ -140,7 +140,7 @@ func readerHelp(isDraft bool, hasLinks bool) string { // inboxHelp returns the one-line help string for the inbox view. func inboxHelp(folder string) string { - 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"} + 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"} _ = folder if folder == "ToScreen" { base = []string{"I approve", "O block", "F feed", "P papertrail", "q back"} diff --git a/internal/ui/search.go b/internal/ui/search.go index dae018a..77a646f 100644 --- a/internal/ui/search.go +++ b/internal/ui/search.go @@ -101,11 +101,6 @@ func (m *Model) handleIMAPSearchResult(msg imapSearchResultMsg) (tea.Model, tea. } m.imapSearchResults = true m.offTabFolder = "Search" - // Prepend folder name to subject so user can see where each result is from - for i := range msg.emails { - folder := msg.emails[i].Folder - msg.emails[i].Subject = "[" + folder + "] " + msg.emails[i].Subject - } m.emails = msg.emails m.markedUIDs = make(map[uint32]bool) m.filterActive = false @@ -141,6 +136,7 @@ func (m Model) fetchEverythingCmd() tea.Cmd { // handleEverythingResult displays the "Everything" view. func (m *Model) handleEverythingResult(msg everythingResultMsg) (tea.Model, tea.Cmd) { m.loading = false + m.imapSearchText = "" if msg.err != nil { m.status = "Everything: " + msg.err.Error() m.isError = true @@ -151,10 +147,6 @@ func (m *Model) handleEverythingResult(msg everythingResultMsg) (tea.Model, tea. return m, nil } m.offTabFolder = "Everything" - // Prepend folder name so user knows where each email is - for i := range msg.emails { - msg.emails[i].Subject = "[" + msg.emails[i].Folder + "] " + msg.emails[i].Subject - } m.emails = msg.emails m.markedUIDs = make(map[uint32]bool) m.filterActive = false @@ -214,6 +206,7 @@ func (m Model) fetchConversationCmd(e *imap.Email) tea.Cmd { // handleConversationResult displays the conversation/thread view. func (m *Model) handleConversationResult(msg conversationResultMsg) (tea.Model, tea.Cmd) { m.loading = false + m.imapSearchResults = false if msg.err != nil { m.status = "Thread: " + msg.err.Error() m.isError = true @@ -224,9 +217,6 @@ func (m *Model) handleConversationResult(msg conversationResultMsg) (tea.Model, return m, nil } m.offTabFolder = "Thread" - for i := range msg.emails { - msg.emails[i].Subject = "[" + msg.emails[i].Folder + "] " + msg.emails[i].Subject - } m.emails = msg.emails m.markedUIDs = make(map[uint32]bool) m.filterActive = false