From 9c0ca254f5f8a92d70a0ce6e73f21dd90d9ba058 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 29 Mar 2026 19:43:58 +0700 Subject: [PATCH 1/4] Show delivery failure reason in message view header Display the postqueue failure reason right-aligned in red on the message header line so it's immediately visible when viewing a message. Co-Authored-By: Claude Sonnet 4.6 --- model.go | 21 +++++++++++++++++++-- styles.go | 1 + 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/model.go b/model.go index ddf4db9..197b432 100644 --- a/model.go +++ b/model.go @@ -10,6 +10,7 @@ import ( "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" ) @@ -366,7 +367,17 @@ func (m Model) View() string { return m.list.View() + "\n" + m.renderBottom() case stateMessage: - header := titleStyle.Render(fmt.Sprintf("Message: %s", m.currentID)) + title := titleStyle.Render(fmt.Sprintf("Message: %s", m.currentID)) + header := title + if idx, ok := m.entryIndex[m.currentID]; ok { + if reason := m.entries[idx].Reason; reason != "" { + gap := m.width - lipgloss.Width(title) - lipgloss.Width(reason) - 2 + if gap < 1 { + gap = 1 + } + header = title + strings.Repeat(" ", gap) + reasonStyle.Render(reason) + } + } scrollPct := int(m.viewport.ScrollPercent() * 100) headersHint := "H: full headers" if m.showFullHeaders { @@ -452,7 +463,13 @@ func (m Model) renderBottom() string { label := fmt.Sprintf(" Fetching subjects: %d / %d ", m.loadingDone, m.loadingTotal) return dimStyle.Render(label) + "\n " + m.progress.View() } - return statusBarStyle.Render( + status := statusBarStyle.Render( fmt.Sprintf(" %d message(s) │ Enter: open │ r: refresh │ q: quit ", len(m.list.Items())), ) + if item, ok := m.list.SelectedItem().(queueItem); ok { + if reason := item.entry.Reason; reason != "" { + return status + "\n " + reasonStyle.Render(reason) + } + } + return status } diff --git a/styles.go b/styles.go index c748b33..cffc818 100644 --- a/styles.go +++ b/styles.go @@ -12,4 +12,5 @@ var ( partSepStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) attachStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")) headerKeyStyle = lipgloss.NewStyle().Bold(true) + reasonStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) ) From 9ba845e5f493e9dfe98c4790dc460a07fe01e3f4 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 29 Mar 2026 19:45:40 +0700 Subject: [PATCH 2/4] Add F keybinding to flush the mail queue via postqueue -f Co-Authored-By: Claude Sonnet 4.6 --- README.md | 2 ++ model.go | 14 ++++++++++++-- msgs.go | 20 ++++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c192799..ef1b97f 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ go install github.com/nemunaire/mqv@latest | `↑` / `↓` | Navigate messages | | `Enter` | Open message | | `r` | Refresh queue | +| `F` | Flush queue (`postqueue -f`) | | `q` | Quit | Subjects are fetched lazily in parallel via `postcat` as the list loads; a progress bar tracks completion. @@ -59,6 +60,7 @@ Subjects are fetched lazily in parallel via `postcat` as the list loads; a progr | `↑↓` / `Space` / `PgUp` / `PgDn` | Scroll | | `H` | Toggle full headers / short headers | | `s` | Save raw EML to `~/QUEUEID.eml` | +| `F` | Requeue message (`postsuper -r`) | | `v` | Browse MIME parts | | `q` | Back to queue list | diff --git a/model.go b/model.go index 197b432..1b32c28 100644 --- a/model.go +++ b/model.go @@ -210,6 +210,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.list.SetItems(nil) _ = m.progress.SetPercent(0) return m, loadQueueCmd() + case "F": + m.entries = nil + m.entryIndex = nil + m.loadingTotal = 0 + m.loadingDone = 0 + m.list.SetItems(nil) + _ = m.progress.SetPercent(0) + return m, flushQueueCmd() case "enter": if item, ok := m.list.SelectedItem().(queueItem); ok { return m, loadMessageCmd(item.entry.ID) @@ -254,6 +262,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.refreshViewport() m.viewport.GotoTop() return m, nil + case "F": + return m, requeueMessageCmd(m.currentID) case "v": m.parts = extractParts(m.currentRaw) m.partsCursor = 0 @@ -384,7 +394,7 @@ func (m Model) View() string { headersHint = "H: short headers" } status := statusBarStyle.Render( - fmt.Sprintf(" ↑↓/SPC/PgUp/Dn: scroll │ s: save EML │ v: parts │ %s │ q: back │ %d%% ", headersHint, scrollPct), + fmt.Sprintf(" ↑↓/SPC/PgUp/Dn: scroll │ s: save EML │ F: requeue │ v: parts │ %s │ q: back │ %d%% ", headersHint, scrollPct), ) notice := "" if m.messageSaving { @@ -464,7 +474,7 @@ func (m Model) renderBottom() string { return dimStyle.Render(label) + "\n " + m.progress.View() } status := statusBarStyle.Render( - fmt.Sprintf(" %d message(s) │ Enter: open │ r: refresh │ q: quit ", len(m.list.Items())), + fmt.Sprintf(" %d message(s) │ Enter: open │ r: refresh │ F: flush │ q: quit ", len(m.list.Items())), ) if item, ok := m.list.SelectedItem().(queueItem); ok { if reason := item.entry.Reason; reason != "" { diff --git a/msgs.go b/msgs.go index 9bf3bb9..00ef356 100644 --- a/msgs.go +++ b/msgs.go @@ -1,6 +1,8 @@ package main import ( + "os/exec" + tea "github.com/charmbracelet/bubbletea" ) @@ -34,6 +36,24 @@ func loadQueueCmd() tea.Cmd { } } +func flushQueueCmd() tea.Cmd { + return func() tea.Msg { + exec.Command("postqueue", "-f").Run() + entries, err := loadQueue() + if err != nil { + return queueErrMsg{err} + } + return queueParsedMsg{entries} + } +} + +func requeueMessageCmd(id string) tea.Cmd { + return func() tea.Msg { + exec.Command("postsuper", "-r", id).Run() + return nil + } +} + func fetchSubjectCmd(id string) tea.Cmd { return func() tea.Msg { raw, err := fetchHeaders(id) From 3af666978ce3ae22a6b5705aa051c989ca997070 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 29 Mar 2026 19:50:09 +0700 Subject: [PATCH 3/4] Add D keybindings in message view for delete Co-Authored-By: Claude Sonnet 4.6 --- README.md | 1 + model.go | 7 ++++++- msgs.go | 7 +++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ef1b97f..1e46f27 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ Subjects are fetched lazily in parallel via `postcat` as the list loads; a progr | `↑↓` / `Space` / `PgUp` / `PgDn` | Scroll | | `H` | Toggle full headers / short headers | | `s` | Save raw EML to `~/QUEUEID.eml` | +| `D` | Delete message (`postsuper -d`) | | `F` | Requeue message (`postsuper -r`) | | `v` | Browse MIME parts | | `q` | Back to queue list | diff --git a/model.go b/model.go index 1b32c28..fd4aafa 100644 --- a/model.go +++ b/model.go @@ -262,6 +262,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.refreshViewport() m.viewport.GotoTop() return m, nil + case "D": + id := m.currentID + m.state = stateList + m.saveNotice = "" + return m, deleteMessageCmd(id) case "F": return m, requeueMessageCmd(m.currentID) case "v": @@ -394,7 +399,7 @@ func (m Model) View() string { headersHint = "H: short headers" } status := statusBarStyle.Render( - fmt.Sprintf(" ↑↓/SPC/PgUp/Dn: scroll │ s: save EML │ F: requeue │ v: parts │ %s │ q: back │ %d%% ", headersHint, scrollPct), + fmt.Sprintf(" ↑↓/SPC/PgUp/Dn: scroll │ s: save EML │ D: delete │ F: requeue │ v: parts │ %s │ q: back │ %d%% ", headersHint, scrollPct), ) notice := "" if m.messageSaving { diff --git a/msgs.go b/msgs.go index 00ef356..1780287 100644 --- a/msgs.go +++ b/msgs.go @@ -47,6 +47,13 @@ func flushQueueCmd() tea.Cmd { } } +func deleteMessageCmd(id string) tea.Cmd { + return func() tea.Msg { + exec.Command("postsuper", "-d", id).Run() + return nil + } +} + func requeueMessageCmd(id string) tea.Cmd { return func() tea.Msg { exec.Command("postsuper", "-r", id).Run() From 17525622793c981e4dcb41188d834ab178855dcb Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 29 Mar 2026 19:55:52 +0700 Subject: [PATCH 4/4] Show delivery failure reason in red on the queue status bar Right-align the postqueue failure reason in red on the same status bar line, rendered as a separate segment with matching background to avoid breaking the layout. Co-Authored-By: Claude Sonnet 4.6 --- model.go | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/model.go b/model.go index fd4aafa..65d5242 100644 --- a/model.go +++ b/model.go @@ -43,7 +43,7 @@ type Model struct { saveNotice string width int height int - messageSaving bool + messageSaving bool // stateParts fields parts []MessagePart partsCursor int @@ -478,13 +478,17 @@ func (m Model) renderBottom() string { label := fmt.Sprintf(" Fetching subjects: %d / %d ", m.loadingDone, m.loadingTotal) return dimStyle.Render(label) + "\n " + m.progress.View() } - status := statusBarStyle.Render( - fmt.Sprintf(" %d message(s) │ Enter: open │ r: refresh │ F: flush │ q: quit ", len(m.list.Items())), - ) + left := fmt.Sprintf(" %d message(s) │ Enter: open │ r: refresh │ F: flush │ q: quit ", len(m.list.Items())) + reason := "" if item, ok := m.list.SelectedItem().(queueItem); ok { - if reason := item.entry.Reason; reason != "" { - return status + "\n " + reasonStyle.Render(reason) - } + reason = item.entry.Reason } - return status + if reason == "" { + return statusBarStyle.Render(left) + } + right := " " + reason + " " + rightWidth := lipgloss.Width(right) + leftPart := statusBarStyle.Width(m.width - rightWidth).Render(left) + rightPart := lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Background(lipgloss.Color("241")).Render(right) + return leftPart + rightPart }