Compare commits

...

4 commits

Author SHA1 Message Date
1752562279 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 <noreply@anthropic.com>
2026-03-29 19:55:52 +07:00
3af666978c Add D keybindings in message view for delete
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:55:25 +07:00
9ba845e5f4 Add F keybinding to flush the mail queue via postqueue -f
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:55:18 +07:00
9c0ca254f5 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 <noreply@anthropic.com>
2026-03-29 19:48:55 +07:00
4 changed files with 73 additions and 6 deletions

View file

@ -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,8 @@ 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 |

View file

@ -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"
)
@ -42,7 +43,7 @@ type Model struct {
saveNotice string
width int
height int
messageSaving bool
messageSaving bool
// stateParts fields
parts []MessagePart
partsCursor int
@ -209,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)
@ -253,6 +262,13 @@ 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":
m.parts = extractParts(m.currentRaw)
m.partsCursor = 0
@ -366,14 +382,24 @@ 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 {
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 │ D: delete │ F: requeue │ v: parts │ %s │ q: back │ %d%% ", headersHint, scrollPct),
)
notice := ""
if m.messageSaving {
@ -452,7 +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()
}
return statusBarStyle.Render(
fmt.Sprintf(" %d message(s) │ Enter: open │ r: refresh │ 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 {
reason = item.entry.Reason
}
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
}

27
msgs.go
View file

@ -1,6 +1,8 @@
package main
import (
"os/exec"
tea "github.com/charmbracelet/bubbletea"
)
@ -34,6 +36,31 @@ 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 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()
return nil
}
}
func fetchSubjectCmd(id string) tea.Cmd {
return func() tea.Msg {
raw, err := fetchHeaders(id)

View file

@ -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"))
)