Add parts screen with per-part view and save

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 <noreply@anthropic.com>
This commit is contained in:
nemunaire 2026-03-29 19:23:27 +07:00
commit 5afabd0255
3 changed files with 270 additions and 7 deletions

View file

@ -18,6 +18,91 @@ import (
"golang.org/x/text/encoding/ianaindex" "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{ var wordDecoder = &mime.WordDecoder{
CharsetReader: func(charset string, input io.Reader) (io.Reader, error) { CharsetReader: func(charset string, input io.Reader) (io.Reader, error) {
// Fast path for the most common legacy charsets // Fast path for the most common legacy charsets

180
model.go
View file

@ -1,11 +1,13 @@
package main package main
import ( import (
"bytes"
"fmt" "fmt"
"strings" "strings"
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi"
@ -16,8 +18,10 @@ import (
type appState int type appState int
const ( const (
stateList appState = iota stateList appState = iota
stateMessage appState = iota stateMessage appState = iota
stateParts appState = iota
statePartView appState = iota
) )
// ── Model ───────────────────────────────────────────────────────────────────── // ── Model ─────────────────────────────────────────────────────────────────────
@ -38,6 +42,12 @@ type Model struct {
saveNotice string saveNotice string
width int width int
height int height int
// stateParts fields
parts []MessagePart
partsCursor int
partsSaving bool
partsSaveInput textinput.Model
partSaveNotice string
} }
func initialModel() Model { func initialModel() Model {
@ -55,11 +65,16 @@ func initialModel() Model {
vp := viewport.New(80, 20) vp := viewport.New(80, 20)
ti := textinput.New()
ti.Placeholder = "filename"
ti.CharLimit = 255
return Model{ return Model{
state: stateList, state: stateList,
list: l, list: l,
progress: prog, progress: prog,
viewport: vp, 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 m.viewport.Height = msg.Height - 2
if m.state == stateMessage && m.currentRaw != "" { if m.state == stateMessage && m.currentRaw != "" {
m.refreshViewport() 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 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() m.saveNotice = "Save error: " + msg.err.Error()
return m, nil 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: case tea.KeyMsg:
switch m.state { switch m.state {
@ -202,6 +229,85 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.refreshViewport() m.refreshViewport()
m.viewport.GotoTop() m.viewport.GotoTop()
return m, nil 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 var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg) m.viewport, cmd = m.viewport.Update(msg)
@ -243,13 +349,73 @@ func (m Model) View() string {
headersHint = "H: short headers" headersHint = "H: short headers"
} }
status := statusBarStyle.Render( 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 := "" notice := ""
if m.saveNotice != "" { if m.saveNotice != "" {
notice = "\n" + saveNoticeStyle.Render(m.saveNotice) notice = "\n" + saveNoticeStyle.Render(m.saveNotice)
} }
return header + "\n" + m.viewport.View() + "\n" + status + notice 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 "" return ""

12
msgs.go
View file

@ -19,6 +19,8 @@ type messageLoadedMsg struct {
type messageErrMsg struct{ err error } type messageErrMsg struct{ err error }
type savedMsg struct{ path string } type savedMsg struct{ path string }
type saveErrMsg struct{ err error } type saveErrMsg struct{ err error }
type partSavedMsg struct{ path string }
type partSaveErrMsg struct{ err error }
// ── Tea commands ────────────────────────────────────────────────────────────── // ── Tea commands ──────────────────────────────────────────────────────────────
@ -61,3 +63,13 @@ func saveCmd(id, content string) tea.Cmd {
return savedMsg{path} 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}
}
}