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)