diff --git a/CHANGELOG.md b/CHANGELOG.md index aa13931..9ab7594 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,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 - **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 # 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/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..4e71014 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. @@ -137,7 +137,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 +182,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/model.go b/internal/ui/model.go index 5c93697..a70c22d 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -123,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!) } ) @@ -614,6 +614,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) { @@ -705,7 +724,7 @@ 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 return func() tea.Msg { // Build raw MIME once — reused for both SMTP delivery and Sent copy. @@ -792,6 +811,31 @@ 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, @@ -957,13 +1001,14 @@ func (m Model) batchScreenerCmd(emails []imap.Email, action string) tea.Cmd { } } } - for i, o := range expandedOps { - // 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(expandedOps), err)} - } + 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": @@ -978,7 +1023,25 @@ 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(expandedOps), 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 { + for j := len(undos) - 1; j >= 0; j-- { + u := undos[j] + _, _ = m.imapCli().MoveMessage(nil, u.toFolder, u.uid, u.fromFolder) + } + if restoreErr := sc.Restore(snapshot); restoreErr != nil { + return batchDoneMsg{err: fmt.Errorf("stopped after %d/%d: %w (rollback failed: %v)", i, len(expandedOps), err, restoreErr)} + } + 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) @@ -1732,6 +1795,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // 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{ @@ -2294,6 +2358,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) @@ -2317,7 +2390,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 @@ -2327,7 +2400,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). @@ -2801,22 +2874,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") @@ -2849,17 +2940,23 @@ 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)} }) } @@ -2986,7 +3083,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 @@ -3015,7 +3112,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") @@ -3046,7 +3143,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 } @@ -3056,10 +3153,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)} }) } @@ -3110,15 +3210,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} } } @@ -3128,7 +3227,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") @@ -3162,7 +3261,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 } @@ -3172,10 +3271,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)} }) } @@ -3183,7 +3285,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") @@ -3217,7 +3319,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 } @@ -3227,10 +3329,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)} }) } @@ -3320,7 +3425,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 @@ -3328,7 +3436,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)} }) } @@ -3410,17 +3518,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)} }) } @@ -3443,6 +3554,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. diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 984fbe3..51021b1 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -7,6 +7,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/sspaeti/neomd/internal/config" + "github.com/sspaeti/neomd/internal/imap" ) func TestMaskEmail(t *testing.T) { @@ -141,6 +142,21 @@ func TestPresendSMTPAccount(t *testing.T) { }) } +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{ @@ -262,3 +278,18 @@ func TestUpdatePresendEscRequestsDiscardConfirmation(t *testing.T) { 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/search.go b/internal/ui/search.go index dae018a..24d835b 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 @@ -151,10 +146,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 @@ -224,9 +215,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