mqv/message.go
Pierre-Olivier Mercier 5afabd0255 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>
2026-03-29 19:33:21 +07:00

424 lines
11 KiB
Go

package main
import (
"encoding/base64"
"fmt"
"io"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net/mail"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"golang.org/x/text/encoding/charmap"
"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
switch strings.ToLower(charset) {
case "iso-8859-1", "latin-1":
return charmap.ISO8859_1.NewDecoder().Reader(input), nil
case "windows-1252", "cp1252":
return charmap.Windows1252.NewDecoder().Reader(input), nil
}
// Fall back to IANA index for everything else
enc, err := ianaindex.MIME.Encoding(charset)
if err != nil {
return nil, fmt.Errorf("unsupported charset %q: %w", charset, err)
}
return enc.NewDecoder().Reader(input), nil
},
}
// fetchHeaders runs postcat -qh and returns only the headers.
func fetchHeaders(id string) (string, error) {
out, err := exec.Command("postcat", "-qh", id).Output()
if err != nil {
return "", fmt.Errorf("postcat -qh %s: %w", id, err)
}
return string(out), nil
}
// fetchMessage runs postcat -qbh and returns the raw EML content.
func fetchMessage(id string) (string, error) {
out, err := exec.Command("postcat", "-qbh", id).Output()
if err != nil {
return "", fmt.Errorf("postcat -qbh %s: %w", id, err)
}
return string(out), nil
}
// extractSubject scans raw EML output for the Subject field and decodes it.
// It handles folded headers (RFC 2822 continuation lines) and RFC 2047 encoded words.
// We do not stop at blank lines because postcat output may include envelope records
// separated from the RFC 2822 headers by a blank line.
func extractSubject(raw string) string {
lines := strings.Split(raw, "\n")
for i, line := range lines {
if !strings.HasPrefix(strings.ToLower(line), "subject:") {
continue
}
value := line[len("subject:"):]
// Collect folded continuation lines (start with whitespace)
for j := i + 1; j < len(lines); j++ {
if len(lines[j]) > 0 && (lines[j][0] == ' ' || lines[j][0] == '\t') {
value += " " + strings.TrimSpace(lines[j])
} else {
break
}
}
value = strings.TrimSpace(value)
if decoded, err := wordDecoder.DecodeHeader(value); err == nil {
return decoded
}
return value
}
return "(no subject)"
}
// renderMessage parses raw EML and returns a human-readable string suitable
// for display in the viewport. Headers are RFC 2047 decoded, and the body is
// decoded from quoted-printable or base64 transfer encoding.
// When fullHeaders is true all headers are shown (sorted); otherwise only the
// curated subset (Date, From, To, Cc, Reply-To, Subject) is displayed.
func renderMessage(raw string, fullHeaders bool) string {
msg, err := mail.ReadMessage(strings.NewReader(raw))
if err != nil {
// postcat may prefix envelope lines before RFC 2822 headers; fall back.
return raw
}
var sb strings.Builder
if fullHeaders {
names := make([]string, 0, len(msg.Header))
for name := range msg.Header {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
for _, val := range msg.Header[name] {
decoded, err := wordDecoder.DecodeHeader(val)
if err != nil {
decoded = val
}
sb.WriteString(name)
sb.WriteString(": ")
sb.WriteString(decoded)
sb.WriteByte('\n')
}
}
} else {
for _, name := range []string{"Date", "From", "To", "Cc", "Reply-To", "Subject"} {
val := msg.Header.Get(name)
if val == "" {
continue
}
decoded, err := wordDecoder.DecodeHeader(val)
if err != nil {
decoded = val
}
sb.WriteString(headerKeyStyle.Render(name + ":"))
sb.WriteString(" ")
sb.WriteString(decoded)
sb.WriteByte('\n')
}
}
sb.WriteByte('\n')
// Decode and write the body.
ct := msg.Header.Get("Content-Type")
cte := strings.ToLower(strings.TrimSpace(msg.Header.Get("Content-Transfer-Encoding")))
cd := msg.Header.Get("Content-Disposition")
body := renderPart(ct, cte, cd, msg.Body)
sb.WriteString(body)
return sb.String()
}
// renderPart decodes a single MIME part (or the top-level message body) given
// its Content-Type, Content-Transfer-Encoding, and Content-Disposition header values.
func renderPart(ct, cte, cd string, r io.Reader) string {
mediaType, params, err := mime.ParseMediaType(ct)
if err != nil {
mediaType = "text/plain"
params = map[string]string{}
}
// Decode transfer encoding first.
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 "[multipart message with missing boundary]\n"
}
return renderMultipart(multipart.NewReader(decoded, boundary), mediaType)
}
// Non-text, non-multipart parts are binary attachments — show a placeholder.
if !strings.HasPrefix(mediaType, "text/") {
data, _ := io.ReadAll(decoded)
name := partName(params, cd)
size := len(data)
label := fmt.Sprintf("\n[ Content-Type: %s", mediaType)
if name != "" {
label += " - Filename: " + name
}
if size > 0 {
label += fmt.Sprintf(" - Size: %s", formatSize(size))
}
label += " ]"
return attachStyle.Render(label) + "\n\n"
}
// Decode charset for text parts.
if charset, ok := params["charset"]; ok && charset != "" {
if cr, err := wordDecoder.CharsetReader(charset, decoded); err == nil {
decoded = cr
}
}
data, err := io.ReadAll(decoded)
if err != nil {
return fmt.Sprintf("[error reading body: %v]\n", err)
}
if mediaType == "text/html" {
return renderHTML(string(data))
}
return string(data)
}
// partName extracts a filename/name from Content-Type params or Content-Disposition.
func partName(ctParams map[string]string, cd string) string {
if n := ctParams["name"]; n != "" {
if decoded, err := wordDecoder.DecodeHeader(n); err == nil {
return decoded
}
return n
}
if cd != "" {
if _, cdParams, err := mime.ParseMediaType(cd); err == nil {
if fn := cdParams["filename"]; fn != "" {
if decoded, err := wordDecoder.DecodeHeader(fn); err == nil {
return decoded
}
return fn
}
}
}
return ""
}
// formatSize formats a byte count as a human-readable string.
func formatSize(n int) string {
switch {
case n >= 1024*1024:
return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
case n >= 1024:
return fmt.Sprintf("%.1f kB", float64(n)/1024)
default:
return fmt.Sprintf("%d B", n)
}
}
// renderMultipart walks multipart parts, preferring text/plain. Nested
// multipart/* is handled recursively. Parts in mixed/related messages are
// separated by a colored rule.
func renderMultipart(mr *multipart.Reader, parentType string) string {
type candidate struct {
mediaType string
content string
}
var parts []candidate
for {
p, err := mr.NextPart()
if err != nil { // io.EOF or real error
break
}
partCT := p.Header.Get("Content-Type")
partCTE := strings.ToLower(strings.TrimSpace(p.Header.Get("Content-Transfer-Encoding")))
partCD := p.Header.Get("Content-Disposition")
text := renderPart(partCT, partCTE, partCD, p)
mt, _, _ := mime.ParseMediaType(partCT)
parts = append(parts, candidate{mt, text})
}
if parentType == "multipart/alternative" {
// Prefer plain text.
for _, c := range parts {
if c.mediaType == "text/plain" {
return c.content
}
}
// Fall back to first non-empty part.
for _, c := range parts {
if strings.TrimSpace(c.content) != "" {
return c.content
}
}
return ""
}
// For mixed/related/etc, join non-empty parts with a colored separator.
var sb strings.Builder
first := true
for _, c := range parts {
if strings.TrimSpace(c.content) == "" {
continue
}
if !first {
label := "\n── " + c.mediaType + " "
rule := label + strings.Repeat("─", max(0, 60-len(label)))
sb.WriteString(partSepStyle.Render(rule))
sb.WriteByte('\n')
}
sb.WriteString(c.content)
if !strings.HasSuffix(c.content, "\n") {
sb.WriteByte('\n')
}
first = false
}
return sb.String()
}
// renderHTML renders HTML content to plain text using w3m if available,
// otherwise returns the raw HTML.
func renderHTML(html string) string {
w3m, err := exec.LookPath("w3m")
if err != nil {
return html
}
f, err := os.CreateTemp("", "postfixmutt-*.html")
if err != nil {
return html
}
defer os.Remove(f.Name())
if _, err := f.WriteString(html); err != nil {
f.Close()
return html
}
f.Close()
out, err := exec.Command(w3m, "-T", "text/html", "-dump", "-o", "display_link_number=1", f.Name()).Output()
if err != nil {
return html
}
return string(out)
}
// saveMessage writes the raw EML content to ~/QUEUEID.eml.
func saveMessage(id, content string) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("could not determine home directory: %w", err)
}
path := filepath.Join(home, id+".eml")
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
return "", fmt.Errorf("write %s: %w", path, err)
}
return path, nil
}