From 5afabd0255f0c36b8c45badd4b9ec9423143614a Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 29 Mar 2026 19:23:27 +0700 Subject: [PATCH] Add parts screen with per-part view and save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `stateParts` screen (v from message view) listing all MIME leaf parts in a table with content-type, name, and size. Navigation with ↑↓/j/k, Enter to render text/* parts in a scrollable viewport (statePartView), s to save any part to the current directory with a prompted filename defaulting to the part's name. Co-Authored-By: Claude Sonnet 4.6 --- message.go | 85 +++++++++++++++++++++++++ model.go | 180 ++++++++++++++++++++++++++++++++++++++++++++++++++--- msgs.go | 12 ++++ 3 files changed, 270 insertions(+), 7 deletions(-) diff --git a/message.go b/message.go index 22b56ad..37c113a 100644 --- a/message.go +++ b/message.go @@ -18,6 +18,91 @@ import ( "golang.org/x/text/encoding/ianaindex" ) +// MessagePart holds metadata and raw decoded bytes for a single MIME leaf part. +type MessagePart struct { + Index int + MediaType string + CT string // full Content-Type header value (for charset info) + Name string + Size int + Data []byte +} + +// extractParts parses raw EML and returns a flat list of all MIME leaf parts. +func extractParts(raw string) []MessagePart { + msg, err := mail.ReadMessage(strings.NewReader(raw)) + if err != nil { + return nil + } + ct := msg.Header.Get("Content-Type") + cte := strings.ToLower(strings.TrimSpace(msg.Header.Get("Content-Transfer-Encoding"))) + cd := msg.Header.Get("Content-Disposition") + var parts []MessagePart + counter := 0 + collectParts(ct, cte, cd, msg.Body, &parts, &counter) + return parts +} + +func collectParts(ct, cte, cd string, r io.Reader, out *[]MessagePart, n *int) { + mediaType, params, err := mime.ParseMediaType(ct) + if err != nil { + mediaType = "text/plain" + params = map[string]string{} + } + + var decoded io.Reader + switch cte { + case "quoted-printable": + decoded = quotedprintable.NewReader(r) + case "base64": + decoded = base64.NewDecoder(base64.StdEncoding, r) + default: + decoded = r + } + + if strings.HasPrefix(mediaType, "multipart/") { + boundary := params["boundary"] + if boundary == "" { + return + } + mr := multipart.NewReader(decoded, boundary) + for { + p, err := mr.NextPart() + if err != nil { + break + } + partCT := p.Header.Get("Content-Type") + partCTE := strings.ToLower(strings.TrimSpace(p.Header.Get("Content-Transfer-Encoding"))) + partCD := p.Header.Get("Content-Disposition") + collectParts(partCT, partCTE, partCD, p, out, n) + } + return + } + + data, _ := io.ReadAll(decoded) + *n++ + *out = append(*out, MessagePart{ + Index: *n, + MediaType: mediaType, + CT: ct, + Name: partName(params, cd), + Size: len(data), + Data: data, + }) +} + +// savePart writes data to the named file in the current working directory. +func savePart(data []byte, name string) (string, error) { + if name == "" { + name = "attachment" + } + path := filepath.Base(name) + if err := os.WriteFile(path, data, 0600); err != nil { + return "", fmt.Errorf("write %s: %w", path, err) + } + return path, nil +} + var wordDecoder = &mime.WordDecoder{ CharsetReader: func(charset string, input io.Reader) (io.Reader, error) { // Fast path for the most common legacy charsets diff --git a/model.go b/model.go index 3d53409..4efe547 100644 --- a/model.go +++ b/model.go @@ -1,11 +1,13 @@ package main import ( + "bytes" "fmt" "strings" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/progress" + "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/ansi" @@ -16,8 +18,10 @@ import ( type appState int const ( - stateList appState = iota - stateMessage appState = iota + stateList appState = iota + stateMessage appState = iota + stateParts appState = iota + statePartView appState = iota ) // ── Model ───────────────────────────────────────────────────────────────────── @@ -38,6 +42,12 @@ type Model struct { saveNotice string width int height int + // stateParts fields + parts []MessagePart + partsCursor int + partsSaving bool + partsSaveInput textinput.Model + partSaveNotice string } func initialModel() Model { @@ -55,11 +65,16 @@ func initialModel() Model { vp := viewport.New(80, 20) + ti := textinput.New() + ti.Placeholder = "filename" + ti.CharLimit = 255 + return Model{ - state: stateList, - list: l, - progress: prog, - viewport: vp, + state: stateList, + list: l, + progress: prog, + viewport: vp, + partsSaveInput: ti, } } @@ -101,6 +116,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewport.Height = msg.Height - 2 if m.state == stateMessage && m.currentRaw != "" { m.refreshViewport() + } else if m.state == statePartView && len(m.parts) > 0 { + p := m.parts[m.partsCursor] + content := ansi.Wrap(renderPart(p.CT, "", "", bytes.NewReader(p.Data)), m.viewport.Width, "") + m.viewport.SetContent(content) } return m, nil @@ -166,6 +185,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.saveNotice = "Save error: " + msg.err.Error() return m, nil + case partSavedMsg: + m.partSaveNotice = "Saved: " + msg.path + return m, nil + + case partSaveErrMsg: + m.partSaveNotice = "Save error: " + msg.err.Error() + return m, nil + case tea.KeyMsg: switch m.state { @@ -202,6 +229,85 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.refreshViewport() m.viewport.GotoTop() return m, nil + case "v": + m.parts = extractParts(m.currentRaw) + m.partsCursor = 0 + m.partsSaving = false + m.partSaveNotice = "" + m.partsSaveInput.SetValue("") + m.partsSaveInput.Blur() + m.state = stateParts + return m, nil + } + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + + case stateParts: + if m.partsSaving { + switch msg.String() { + case "esc": + m.partsSaving = false + m.partsSaveInput.Blur() + return m, nil + case "enter": + name := strings.TrimSpace(m.partsSaveInput.Value()) + part := m.parts[m.partsCursor] + m.partsSaving = false + m.partsSaveInput.Blur() + return m, savePartCmd(part.Data, name) + default: + var cmd tea.Cmd + m.partsSaveInput, cmd = m.partsSaveInput.Update(msg) + return m, cmd + } + } + switch msg.String() { + case "q", "esc": + m.state = stateMessage + m.partSaveNotice = "" + return m, nil + case "ctrl+c": + return m, tea.Quit + case "up", "k": + if m.partsCursor > 0 { + m.partsCursor-- + } + return m, nil + case "down", "j": + if m.partsCursor < len(m.parts)-1 { + m.partsCursor++ + } + return m, nil + case "enter": + if len(m.parts) > 0 { + p := m.parts[m.partsCursor] + if strings.HasPrefix(p.MediaType, "text/") { + content := ansi.Wrap(renderPart(p.CT, "", "", bytes.NewReader(p.Data)), m.viewport.Width, "") + m.viewport.SetContent(content) + m.viewport.GotoTop() + m.state = statePartView + return m, nil + } + } + case "s": + if len(m.parts) > 0 { + m.partsSaving = true + m.partSaveNotice = "" + m.partsSaveInput.SetValue(m.parts[m.partsCursor].Name) + m.partsSaveInput.CursorEnd() + m.partsSaveInput.Focus() + return m, textinput.Blink + } + } + + case statePartView: + switch msg.String() { + case "q", "esc": + m.state = stateParts + return m, nil + case "ctrl+c": + return m, tea.Quit } var cmd tea.Cmd m.viewport, cmd = m.viewport.Update(msg) @@ -243,13 +349,73 @@ func (m Model) View() string { headersHint = "H: short headers" } status := statusBarStyle.Render( - fmt.Sprintf(" ↑↓/SPC/PgUp/Dn: scroll │ s: save EML │ %s │ q: back │ %d%% ", headersHint, scrollPct), + fmt.Sprintf(" ↑↓/SPC/PgUp/Dn: scroll │ s: save EML │ v: parts │ %s │ q: back │ %d%% ", headersHint, scrollPct), ) notice := "" if m.saveNotice != "" { notice = "\n" + saveNoticeStyle.Render(m.saveNotice) } return header + "\n" + m.viewport.View() + "\n" + status + notice + + case stateParts: + title := titleStyle.Render(fmt.Sprintf("Parts: %s", m.currentID)) + + // Compute name column width from remaining terminal space. + // Columns: #(3) + gap(2) + CT(30) + gap(2) + size(10) + gap(2) = 49 + nameW := m.width - 49 + if nameW < 8 { + nameW = 8 + } + + header := dimStyle.Render(fmt.Sprintf( + "%-3s %-30s %-*s %10s", + "#", "Content-Type", nameW, "Name", "Size", + )) + + var rows strings.Builder + for i, p := range m.parts { + name := p.Name + if name == "" { + name = "-" + } + if len(name) > nameW { + name = name[:nameW-1] + "…" + } + line := fmt.Sprintf( + "%-3d %-30s %-*s %10s", + p.Index, p.MediaType, nameW, name, formatSize(p.Size), + ) + if i == m.partsCursor { + rows.WriteString(selectedStyle.Render(line)) + } else { + rows.WriteString(line) + } + rows.WriteByte('\n') + } + if len(m.parts) == 0 { + rows.WriteString(dimStyle.Render(" (no parts found)")) + rows.WriteByte('\n') + } + + status := statusBarStyle.Render(" ↑↓/j/k: navigate │ enter: view text part │ s: save part │ q/esc: back ") + + bottom := "" + if m.partsSaving { + bottom = "\n Save as: " + m.partsSaveInput.View() + } else if m.partSaveNotice != "" { + bottom = "\n" + saveNoticeStyle.Render(m.partSaveNotice) + } + + return title + "\n" + header + "\n" + rows.String() + status + bottom + + case statePartView: + p := m.parts[m.partsCursor] + title := titleStyle.Render(fmt.Sprintf("Part %d: %s", p.Index, p.MediaType)) + scrollPct := int(m.viewport.ScrollPercent() * 100) + status := statusBarStyle.Render( + fmt.Sprintf(" ↑↓/SPC/PgUp/Dn: scroll │ q/esc: back to parts │ %d%% ", scrollPct), + ) + return title + "\n" + m.viewport.View() + "\n" + status } return "" diff --git a/msgs.go b/msgs.go index c0b6e73..30fdb18 100644 --- a/msgs.go +++ b/msgs.go @@ -19,6 +19,8 @@ type messageLoadedMsg struct { type messageErrMsg struct{ err error } type savedMsg struct{ path string } type saveErrMsg struct{ err error } +type partSavedMsg struct{ path string } +type partSaveErrMsg struct{ err error } // ── Tea commands ────────────────────────────────────────────────────────────── @@ -61,3 +63,13 @@ func saveCmd(id, content string) tea.Cmd { return savedMsg{path} } } + +func savePartCmd(data []byte, name string) tea.Cmd { + return func() tea.Msg { + path, err := savePart(data, name) + if err != nil { + return partSaveErrMsg{err} + } + return partSavedMsg{path} + } +}