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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue