Initial commit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
fcc6f3eb4c
10 changed files with 1002 additions and 0 deletions
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -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.
|
||||||
32
go.mod
Normal file
32
go.mod
Normal file
|
|
@ -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
|
||||||
|
)
|
||||||
56
go.sum
Normal file
56
go.sum
Normal file
|
|
@ -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=
|
||||||
50
item.go
Normal file
50
item.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
16
main.go
Normal file
16
main.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
339
message.go
Normal file
339
message.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
266
model.go
Normal file
266
model.go
Normal file
|
|
@ -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())),
|
||||||
|
)
|
||||||
|
}
|
||||||
63
msgs.go
Normal file
63
msgs.go
Normal file
|
|
@ -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}
|
||||||
|
}
|
||||||
|
}
|
||||||
144
queue.go
Normal file
144
queue.go
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
15
styles.go
Normal file
15
styles.go
Normal file
|
|
@ -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)
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue