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"
)
// 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