feat(mailbox): compose & reply in the AgentMail TUI

The interactive reader (ssh mail@ / hub Mail) was read-only; the send path
existed only for agents (bot mode). Add compose (c), reply (r), reply-all (a)
to the TUI with a To/Cc/Subject/Body form (tab/arrows to move fields, ctrl+d
send, esc cancel). Reply prefills + quotes the original and threads via
In-Reply-To. Tests drive the model key-by-key through send.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-23 14:09:18 +00:00
parent 045acc965a
commit 1dbb2c70e2
2 changed files with 280 additions and 4 deletions

View file

@ -5,6 +5,8 @@ import (
"errors"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
)
func seeded() *MemoryTransport {
@ -132,6 +134,66 @@ func TestSendAndReply(t *testing.T) {
}
}
func TestComposeTUISend(t *testing.T) {
tr := seeded()
c := paidClient(tr)
var m tea.Model = readerModel{c: c, ctx: context.Background(), mailbox: Inbox}
key := func(t tea.KeyType) tea.KeyMsg { return tea.KeyMsg{Type: t} }
typeStr := func(s string) {
for _, r := range s {
m, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
}
}
typeStr("c") // open compose from the list
if m.(readerModel).mode != modeCompose {
t.Fatalf("expected compose mode, got %v", m.(readerModel).mode)
}
typeStr("bob@example.com")
m, _ = m.Update(key(tea.KeyEnter)) // To -> Cc
m, _ = m.Update(key(tea.KeyEnter)) // Cc -> Subject
typeStr("Hello")
m, _ = m.Update(key(tea.KeyTab)) // Subject -> Body
typeStr("first line")
m, _ = m.Update(key(tea.KeyEnter)) // newline in body
typeStr("second line")
_, cmd := m.Update(key(tea.KeyCtrlD)) // send
if cmd == nil {
t.Fatal("ctrl+d produced no command")
}
if _, ok := cmd().(sentMsg); !ok {
t.Fatalf("expected sentMsg, got %T", cmd())
}
sent, _ := tr.ListMessages(context.Background(), ListOptions{Mailbox: Sent})
if len(sent) != 1 {
t.Fatalf("want 1 sent, got %d", len(sent))
}
if sent[0].To[0].Address != "bob@example.com" || sent[0].Subject != "Hello" {
t.Fatalf("bad draft: %+v", sent[0])
}
}
func TestComposeReplyPrefill(t *testing.T) {
tr := seeded()
c := paidClient(tr)
m := readerModel{c: c, ctx: context.Background(), mailbox: Inbox}
orig, _, _ := c.Read(context.Background(), Inbox, 1, true)
m.current = orig
m.mode = modeMessage
m.startReply(false)
if m.mode != modeCompose {
t.Fatal("reply did not enter compose mode")
}
if m.compose.to != "carol@example.com" || m.compose.subject != "Re: Welcome" {
t.Fatalf("reply prefill wrong: to=%q subject=%q", m.compose.to, m.compose.subject)
}
if m.compose.inReplyTo == "" || m.compose.focus != 3 {
t.Fatalf("reply threading/focus wrong: %+v", m.compose)
}
}
func TestFlagAndDelete(t *testing.T) {
tr := seeded()
c := paidClient(tr)

View file

@ -34,8 +34,22 @@ type readerMode int
const (
modeList readerMode = iota
modeMessage
modeCompose
)
// composeState holds an in-progress draft. Editing is intentionally simple
// (append + backspace at the end of each field), matching the byte-by-byte
// readLine philosophy in cmd/agentbbs — a full cursor editor is overkill for a
// BBS compose box.
type composeState struct {
to, cc, subject, body string
focus int // 0=to 1=cc 2=subject 3=body
inReplyTo string
sending bool
}
const composeFields = 4
type readerModel struct {
c *Client
ctx context.Context
@ -45,6 +59,7 @@ type readerModel struct {
rows []MessageSummary
cursor int
current Message
compose composeState
status string
errText string
width int
@ -54,6 +69,7 @@ type readerModel struct {
type rowsMsg struct{ rows []MessageSummary }
type openedMsg struct{ msg Message }
type actionDoneMsg struct{ status string }
type sentMsg struct{ status string }
type errMsg struct{ err error }
func (m readerModel) Init() tea.Cmd { return m.loadInbox() }
@ -97,7 +113,13 @@ func (m readerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case actionDoneMsg:
m.status = msg.status
return m, m.loadInbox()
case sentMsg:
m.compose = composeState{}
m.mode = modeList
m.status = msg.status
return m, m.loadInbox()
case errMsg:
m.compose.sending = false
m.errText = msg.err.Error()
case tea.KeyMsg:
return m.onKey(msg)
@ -107,6 +129,9 @@ func (m readerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m readerModel) onKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
m.errText = ""
if m.mode == modeCompose {
return m.onComposeKey(k)
}
if m.mode == modeMessage {
switch k.String() {
case "q", "ctrl+c":
@ -114,6 +139,15 @@ func (m readerModel) onKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
case "b", "esc", "left", "h":
m.mode = modeList
return m, m.loadInbox()
case "r":
m.startReply(false)
return m, nil
case "a":
m.startReply(true)
return m, nil
case "c":
m.startCompose()
return m, nil
case "f":
uid := m.current.UID
flagged := !m.current.Flagged
@ -150,6 +184,9 @@ func (m readerModel) onKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
case "r":
m.status = "refreshing…"
return m, m.loadInbox()
case "c":
m.startCompose()
return m, nil
case "enter", "l", "right":
if len(m.rows) > 0 {
return m, m.open(m.rows[m.cursor].UID)
@ -158,12 +195,152 @@ func (m readerModel) onKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
// startCompose opens a blank compose form.
func (m *readerModel) startCompose() {
m.compose = composeState{focus: 0}
m.mode = modeCompose
}
// startReply opens a compose form pre-filled from the open message. replyAll
// also carries the other recipients into Cc (minus the member's own address).
func (m *readerModel) startReply(replyAll bool) {
orig := m.current
to := orig.From
if orig.ReplyTo != nil {
to = *orig.ReplyTo
}
self := strings.ToLower(m.c.Address())
var cc []string
if replyAll {
for _, a := range append(append([]Address{}, orig.To...), orig.CC...) {
la := strings.ToLower(a.Address)
if la != self && la != strings.ToLower(to.Address) {
cc = append(cc, a.Address)
}
}
}
subject := orig.Subject
if !strings.HasPrefix(strings.ToLower(subject), "re:") {
subject = "Re: " + subject
}
m.compose = composeState{
to: to.Address,
cc: strings.Join(cc, ", "),
subject: subject,
body: quoteBody(orig),
focus: 3, // land in the body to type the reply
inReplyTo: orig.MessageID,
}
m.mode = modeCompose
}
// onComposeKey edits the focused field. Tab/shift-tab cycle fields; ctrl+d sends;
// esc cancels. enter inserts a newline in the body and advances otherwise.
func (m readerModel) onComposeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.compose.sending {
return m, nil // ignore input while the send is in flight
}
field := m.composeField()
switch k.String() {
case "ctrl+c":
return m, tea.Quit
case "esc":
m.mode = modeList
return m, m.loadInbox()
case "ctrl+d":
m.compose.sending = true
m.status = "sending…"
return m, m.sendCompose()
case "tab", "down":
m.compose.focus = (m.compose.focus + 1) % composeFields
return m, nil
case "shift+tab", "up":
m.compose.focus = (m.compose.focus + composeFields - 1) % composeFields
return m, nil
case "enter":
if m.compose.focus == 3 {
*field += "\n"
} else {
m.compose.focus++
}
return m, nil
case "backspace":
if r := []rune(*field); len(r) > 0 {
*field = string(r[:len(r)-1])
}
return m, nil
default:
if s := k.String(); len([]rune(s)) == 1 {
*field += s
} else if k.Type == tea.KeySpace {
*field += " "
}
return m, nil
}
}
// composeField returns a pointer to the currently focused field's text.
func (m *readerModel) composeField() *string {
switch m.compose.focus {
case 0:
return &m.compose.to
case 1:
return &m.compose.cc
case 2:
return &m.compose.subject
default:
return &m.compose.body
}
}
// sendCompose builds a Draft from the form and sends it via the client.
func (m readerModel) sendCompose() tea.Cmd {
cs := m.compose
return func() tea.Msg {
d := Draft{
To: parseAddrList(cs.to),
CC: parseAddrList(cs.cc),
Subject: cs.subject,
Text: cs.body,
InReplyTo: cs.inReplyTo,
}
if _, err := m.c.Send(m.ctx, d); err != nil {
return errMsg{err}
}
return sentMsg{status: "sent → " + cs.to}
}
}
// parseAddrList splits a comma-separated header value into addresses.
func parseAddrList(raw string) []Address {
var out []Address
for _, part := range strings.Split(raw, ",") {
if p := strings.TrimSpace(part); p != "" {
out = append(out, ParseAddress(p))
}
}
return out
}
// quoteBody renders the original message as a quoted reply body.
func quoteBody(orig Message) string {
var b strings.Builder
b.WriteString("\n\nOn " + orig.Date.Format("2006-01-02 15:04") + ", " + FormatAddress(orig.From) + " wrote:\n")
for _, line := range strings.Split(orig.Text, "\n") {
b.WriteString("> " + line + "\n")
}
return b.String()
}
func (m readerModel) View() string {
var b strings.Builder
b.WriteString(mhTitle.Render("AgentMail") + mhDim.Render(" · "+m.c.Address()) + "\n\n")
if m.mode == modeMessage {
switch m.mode {
case modeMessage:
b.WriteString(m.viewMessage())
} else {
case modeCompose:
b.WriteString(m.viewCompose())
default:
b.WriteString(m.viewList())
}
if m.errText != "" {
@ -202,7 +379,44 @@ func (m readerModel) viewList() string {
b.WriteString(" " + mhDim.Render(r.Date.Format("2006-01-02 15:04")+" · "+r.Snippet) + "\n")
}
b.WriteString("\n" + mhDim.Render(m.status))
b.WriteString("\n" + mhDim.Render("↑/↓ move · enter open · r refresh · q quit"))
b.WriteString("\n" + mhDim.Render("↑/↓ move · enter open · c compose · r refresh · q quit"))
return b.String()
}
func (m readerModel) viewCompose() string {
cs := m.compose
var b strings.Builder
title := "Compose"
if cs.inReplyTo != "" {
title = "Reply"
}
b.WriteString(mhUnseen.Render(title) + "\n\n")
field := func(idx int, label, val string) {
caret := " "
lbl := mhDim.Render(label)
if cs.focus == idx {
caret = mhCursor.Render(" ")
lbl = mhTitle.Render(label)
val += "▏"
}
b.WriteString(caret + lbl + val + "\n")
}
field(0, "To: ", cs.to)
field(1, "Cc: ", cs.cc)
field(2, "Subject: ", cs.subject)
b.WriteString("\n")
bodyLabel := mhDim.Render("Body:")
if cs.focus == 3 {
bodyLabel = mhTitle.Render("Body:")
}
b.WriteString(bodyLabel + "\n")
body := cs.body
if cs.focus == 3 {
body += "▏"
}
b.WriteString(body + "\n")
b.WriteString("\n" + mhDim.Render(m.status))
b.WriteString("\n" + mhDim.Render("tab/↑↓ field · enter newline(body) · ctrl+d send · esc cancel"))
return b.String()
}
@ -224,7 +438,7 @@ func (m readerModel) viewMessage() string {
b.WriteString(mhDim.Render("Attach: ") + strings.Join(names, ", ") + "\n")
}
b.WriteString("\n" + msg.Text + "\n")
b.WriteString("\n" + mhDim.Render("b back · f flag · x delete · q quit"))
b.WriteString("\n" + mhDim.Render("b back · r reply · a reply-all · c compose · f flag · x delete · q quit"))
return b.String()
}