commit fcc6f3eb4ca92dc174811a422a435a187244639e Author: nemunaire Date: Sun Mar 29 14:27:46 2026 +0200 Initial commit Co-Authored-By: Claude Sonnet 4.6 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f219545 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 nemunaire + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..fd258ae --- /dev/null +++ b/go.mod @@ -0,0 +1,32 @@ +module github.com/nemunaire/mqv + +go 1.25.8 + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbles v1.0.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.35.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..78adf78 --- /dev/null +++ b/go.sum @@ -0,0 +1,56 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= diff --git a/item.go b/item.go new file mode 100644 index 0000000..b2788bc --- /dev/null +++ b/item.go @@ -0,0 +1,50 @@ +package main + +import ( + "fmt" + "io" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" +) + +type queueItem struct { + entry QueueEntry +} + +func (q queueItem) FilterValue() string { return q.entry.Sender + " " + q.entry.Subject } +func (q queueItem) Title() string { return q.entry.Subject } +func (q queueItem) Description() string { + return fmt.Sprintf("%-40s %s", q.entry.Sender, q.entry.Date.Format("Jan 02 15:04")) +} + +// itemDelegate renders each queue entry as a single line. +type itemDelegate struct{} + +func (d itemDelegate) Height() int { return 1 } +func (d itemDelegate) Spacing() int { return 0 } +func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } +func (d itemDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { + qi, ok := item.(queueItem) + if !ok { + return + } + e := qi.entry + date := e.Date.Format("Jan _2 15:04") + from := e.Sender + if len(from) > 30 { + from = from[:28] + ".." + } + subj := e.Subject + if subj == "" { + subj = dimStyle.Render("loading…") + } + + line := fmt.Sprintf(" %-10s %-14s %-30s %s", e.ID, date, from, subj) + + if index == m.Index() { + fmt.Fprint(w, selectedStyle.Render(line)) + } else { + fmt.Fprint(w, line) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..b073e28 --- /dev/null +++ b/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" +) + +func main() { + p := tea.NewProgram(initialModel(), tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} diff --git a/message.go b/message.go new file mode 100644 index 0000000..22b56ad --- /dev/null +++ b/message.go @@ -0,0 +1,339 @@ +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" +) + +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 +} diff --git a/model.go b/model.go new file mode 100644 index 0000000..3d53409 --- /dev/null +++ b/model.go @@ -0,0 +1,266 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/progress" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" +) + +// ── App state ───────────────────────────────────────────────────────────────── + +type appState int + +const ( + stateList appState = iota + stateMessage appState = iota +) + +// ── Model ───────────────────────────────────────────────────────────────────── + +type Model struct { + state appState + list list.Model + progress progress.Model + viewport viewport.Model + entries []QueueEntry + entryIndex map[string]int // queue ID → entries index + loadingTotal int + loadingDone int + currentRaw string + currentID string + showFullHeaders bool + err error + saveNotice string + width int + height int +} + +func initialModel() Model { + l := list.New(nil, itemDelegate{}, 80, 20) + l.Title = "Postfix Queue" + l.SetShowStatusBar(false) + l.SetFilteringEnabled(false) + l.Styles.Title = titleStyle + l.SetShowHelp(false) + + prog := progress.New( + progress.WithDefaultGradient(), + progress.WithoutPercentage(), + ) + + vp := viewport.New(80, 20) + + return Model{ + state: stateList, + list: l, + progress: prog, + viewport: vp, + } +} + +// refreshViewport re-renders the current message and sets wrapped content. +func (m *Model) refreshViewport() { + rendered := renderMessage(m.currentRaw, m.showFullHeaders) + headerSection, body, _ := strings.Cut(rendered, "\n\n") + + var result []string + for line := range strings.SplitSeq(headerSection, "\n") { + parts := strings.Split(ansi.Wrap(line, m.viewport.Width-4, ""), "\n") + result = append(result, parts[0]) + for _, continuation := range parts[1:] { + result = append(result, " "+continuation) + } + } + result = append(result, "") + result = append(result, strings.Split(ansi.Wrap(body, m.viewport.Width, ""), "\n")...) + m.viewport.SetContent(strings.Join(result, "\n")) +} + +// ── Init ────────────────────────────────────────────────────────────────────── + +func (m Model) Init() tea.Cmd { + return loadQueueCmd() +} + +// ── Update ──────────────────────────────────────────────────────────────────── + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.progress.Width = msg.Width - 4 + m.list.SetSize(msg.Width, msg.Height-2) + m.viewport.Width = msg.Width + m.viewport.Height = msg.Height - 2 + if m.state == stateMessage && m.currentRaw != "" { + m.refreshViewport() + } + return m, nil + + case queueParsedMsg: + m.entries = msg.entries + m.entryIndex = make(map[string]int, len(msg.entries)) + m.loadingTotal = len(msg.entries) + m.loadingDone = 0 + m.err = nil + + items := make([]list.Item, len(msg.entries)) + for i, e := range msg.entries { + items[i] = queueItem{e} + m.entryIndex[e.ID] = i + } + m.list.SetItems(items) + + if m.loadingTotal == 0 { + return m, nil + } + cmds := make([]tea.Cmd, len(msg.entries)) + for i, e := range msg.entries { + cmds[i] = fetchSubjectCmd(e.ID) + } + return m, tea.Batch(cmds...) + + case subjectFetchedMsg: + if idx, ok := m.entryIndex[msg.id]; ok { + m.entries[idx].Subject = msg.subject + m.list.SetItem(idx, queueItem{m.entries[idx]}) + } + m.loadingDone++ + pct := float64(m.loadingDone) / float64(m.loadingTotal) + return m, m.progress.SetPercent(pct) + + case progress.FrameMsg: + pm, cmd := m.progress.Update(msg) + m.progress = pm.(progress.Model) + return m, cmd + + case queueErrMsg: + m.err = msg.err + return m, nil + + case messageLoadedMsg: + m.currentRaw = msg.content + m.currentID = msg.id + m.showFullHeaders = false + m.refreshViewport() + m.viewport.GotoTop() + m.state = stateMessage + return m, nil + + case messageErrMsg: + m.err = msg.err + return m, nil + + case savedMsg: + m.saveNotice = "Saved: " + msg.path + return m, nil + + case saveErrMsg: + m.saveNotice = "Save error: " + msg.err.Error() + return m, nil + + case tea.KeyMsg: + switch m.state { + + case stateList: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "r": + m.entries = nil + m.entryIndex = nil + m.loadingTotal = 0 + m.loadingDone = 0 + m.list.SetItems(nil) + _ = m.progress.SetPercent(0) + return m, loadQueueCmd() + case "enter": + if item, ok := m.list.SelectedItem().(queueItem); ok { + return m, loadMessageCmd(item.entry.ID) + } + } + + case stateMessage: + switch msg.String() { + case "q": + m.state = stateList + m.saveNotice = "" + return m, nil + case "ctrl+c": + return m, tea.Quit + case "s": + return m, saveCmd(m.currentID, m.currentRaw) + case "H": + m.showFullHeaders = !m.showFullHeaders + m.refreshViewport() + m.viewport.GotoTop() + return m, nil + } + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + } + + var cmd tea.Cmd + switch m.state { + case stateList: + m.list, cmd = m.list.Update(msg) + case stateMessage: + m.viewport, cmd = m.viewport.Update(msg) + } + return m, cmd +} + +// ── View ────────────────────────────────────────────────────────────────────── + +func (m Model) View() string { + if m.err != nil { + return errorStyle.Render("Error: "+m.err.Error()) + "\n\nPress q to quit." + } + + switch m.state { + + case stateList: + if m.entries == nil { + return titleStyle.Render("Postfix Queue") + "\n\n" + + dimStyle.Render(" Fetching queue list…") + } + return m.list.View() + "\n" + m.renderBottom() + + case stateMessage: + header := titleStyle.Render(fmt.Sprintf("Message: %s", m.currentID)) + scrollPct := int(m.viewport.ScrollPercent() * 100) + headersHint := "H: full headers" + if m.showFullHeaders { + headersHint = "H: short headers" + } + status := statusBarStyle.Render( + fmt.Sprintf(" ↑↓/SPC/PgUp/Dn: scroll │ s: save EML │ %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 + } + + return "" +} + +func (m Model) renderBottom() string { + if m.loadingDone < m.loadingTotal { + label := fmt.Sprintf(" Fetching subjects: %d / %d ", m.loadingDone, m.loadingTotal) + return dimStyle.Render(label) + "\n " + m.progress.View() + } + return statusBarStyle.Render( + fmt.Sprintf(" %d message(s) │ Enter: open │ r: refresh │ q: quit ", len(m.list.Items())), + ) +} diff --git a/msgs.go b/msgs.go new file mode 100644 index 0000000..c0b6e73 --- /dev/null +++ b/msgs.go @@ -0,0 +1,63 @@ +package main + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +// ── Tea message types ───────────────────────────────────────────────────────── + +type queueParsedMsg struct{ entries []QueueEntry } // phase 1: structure only +type subjectFetchedMsg struct { // phase 2: one per entry + id string + subject string +} +type queueErrMsg struct{ err error } +type messageLoadedMsg struct { + id string + content string +} +type messageErrMsg struct{ err error } +type savedMsg struct{ path string } +type saveErrMsg struct{ err error } + +// ── Tea commands ────────────────────────────────────────────────────────────── + +func loadQueueCmd() tea.Cmd { + return func() tea.Msg { + entries, err := loadQueue() + if err != nil { + return queueErrMsg{err} + } + return queueParsedMsg{entries} + } +} + +func fetchSubjectCmd(id string) tea.Cmd { + return func() tea.Msg { + raw, err := fetchHeaders(id) + if err != nil { + return subjectFetchedMsg{id: id, subject: "(error)"} + } + return subjectFetchedMsg{id: id, subject: extractSubject(raw)} + } +} + +func loadMessageCmd(id string) tea.Cmd { + return func() tea.Msg { + content, err := fetchMessage(id) + if err != nil { + return messageErrMsg{err} + } + return messageLoadedMsg{id: id, content: content} + } +} + +func saveCmd(id, content string) tea.Cmd { + return func() tea.Msg { + path, err := saveMessage(id, content) + if err != nil { + return saveErrMsg{err} + } + return savedMsg{path} + } +} diff --git a/queue.go b/queue.go new file mode 100644 index 0000000..ed14d3d --- /dev/null +++ b/queue.go @@ -0,0 +1,144 @@ +package main + +import ( + "bufio" + "fmt" + "os/exec" + "sort" + "strings" + "time" +) + +type QueueEntry struct { + ID string + Size int + Date time.Time + Sender string + Subject string + Rcpts []string + Reason string +} + +func loadQueue() ([]QueueEntry, error) { + out, err := exec.Command("postqueue", "-p").Output() + if err != nil { + return nil, fmt.Errorf("postqueue -p: %w", err) + } + entries := parsePostqueue(string(out)) + sort.Slice(entries, func(i, j int) bool { + return entries[i].Date.After(entries[j].Date) + }) + return entries, nil +} + +// parsePostqueue parses the output of `postqueue -p`. +// Each message block is separated by a blank line. +// Format of first line of a block: +// +// QUEUEID[*!]? SIZE DOW MON DD HH:MM:SS SENDER +func parsePostqueue(output string) []QueueEntry { + var entries []QueueEntry + + scanner := bufio.NewScanner(strings.NewReader(output)) + + // Skip header line + for scanner.Scan() { + if strings.HasPrefix(scanner.Text(), "-Queue ID-") { + break + } + } + + var current *QueueEntry + for scanner.Scan() { + line := scanner.Text() + + if line == "" { + if current != nil { + entries = append(entries, *current) + current = nil + } + continue + } + + // Lines starting with a space are reason or recipient lines + if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") { + if current == nil { + continue + } + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "(") { + current.Reason = strings.Trim(trimmed, "()") + } else if trimmed != "" { + current.Rcpts = append(current.Rcpts, trimmed) + } + continue + } + + // Deferral/error reason line (not indented, starts with '(') + if strings.HasPrefix(line, "(") { + if current != nil { + current.Reason = strings.Trim(line, "()") + } + continue + } + + // Skip summary line at the end (e.g. "-- 2 Kbytes in 1 Request.") + if strings.HasPrefix(line, "--") { + if current != nil { + entries = append(entries, *current) + current = nil + } + break + } + + // New message block: parse the header line + entry := parseQueueLine(line) + if entry != nil { + current = entry + } + } + + if current != nil { + entries = append(entries, *current) + } + + return entries +} + +// parseQueueLine parses a line like: +// ABC1234* 1234 Sun Mar 29 10:00:00 sender@example.com +func parseQueueLine(line string) *QueueEntry { + fields := strings.Fields(line) + // Minimum: ID, size, weekday, month, day, time, sender + if len(fields) < 7 { + return nil + } + + id := fields[0] + // Strip trailing status character (* or !) + id = strings.TrimRight(id, "*!") + + var size int + fmt.Sscanf(fields[1], "%d", &size) + + // Date: fields[2] = weekday, [3] = month, [4] = day, [5] = HH:MM:SS + // Use current year as postqueue doesn't show year + dateStr := fmt.Sprintf("%s %s %s %s %d", + fields[2], fields[3], fields[4], fields[5], time.Now().Year()) + t, err := time.Parse(time.ANSIC, dateStr) + if err != nil { + t = time.Time{} + } + + sender := "" + if len(fields) >= 7 { + sender = fields[6] + } + + return &QueueEntry{ + ID: id, + Size: size, + Date: t, + Sender: sender, + } +} diff --git a/styles.go b/styles.go new file mode 100644 index 0000000..c748b33 --- /dev/null +++ b/styles.go @@ -0,0 +1,15 @@ +package main + +import "github.com/charmbracelet/lipgloss" + +var ( + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33")) + selectedStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")) + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + statusBarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Reverse(true).Padding(0, 1) + errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true) + saveNoticeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("82")) + partSepStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + attachStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")) + headerKeyStyle = lipgloss.NewStyle().Bold(true) +)