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:
parent
97a8f7fbcd
commit
5afabd0255
3 changed files with 270 additions and 7 deletions
85
message.go
85
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
|
||||
|
|
|
|||
180
model.go
180
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 ""
|
||||
|
|
|
|||
12
msgs.go
12
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}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue