Add AgentMail: paid-member mailbox reader (TUI + bot mode) on the BBS

internal/mailbox is a transport-agnostic mail client for Founding Lifetime
members: read, search, compose, send, reply, flag, delete.

- types/transport/client: paid-gated facade returning JSON-serializable
  structs (same shapes as @logicsrc/plugin-agentmail)
- memory.go: in-memory transport (tests/dev/reference) + tests
- imap.go/smtp.go: real backend — go-imap/v2 (Dovecot IMAP, master-user
  login) + net/smtp submission via the co-located relay; go-message parses
  bodies/attachments
- reader_tui.go: Bubble Tea reader for humans (list/read/flag/delete)
- bot.go: JSON in/out mode for agents (ssh mail@host list|read|send|…)
- wired as the paid "Mail" hub entry and the ssh mail@ route + auth.MailNames

Connects to the self-hosted Mailu stack at mail.profullstack.com /
smtp.profullstack.com. Rebased onto main; build + vet + tests clean (Go 1.26).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-14 16:49:04 +00:00
parent 6b4db2b934
commit e165ecee26
13 changed files with 1660 additions and 0 deletions

View file

@ -0,0 +1,251 @@
package mailbox
import (
"context"
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/ssh"
)
var (
mhTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
mhDim = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
mhCursor = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
mhUnseen = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e2e8f0"))
mhFlag = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
mhErr = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
mhFrame = lipgloss.NewStyle().Padding(1, 2)
)
// RunReader runs the interactive mail TUI for a member on the session. Used by
// the hub "Mail" entry and the ssh mail@ route (with a PTY).
func RunReader(s ssh.Session, c *Client) error {
m := readerModel{c: c, ctx: s.Context(), mailbox: Inbox, status: "loading…"}
p := tea.NewProgram(m, tea.WithInput(s), tea.WithOutput(s), tea.WithAltScreen())
_, err := p.Run()
return err
}
type readerMode int
const (
modeList readerMode = iota
modeMessage
)
type readerModel struct {
c *Client
ctx context.Context
mailbox string
mode readerMode
rows []MessageSummary
cursor int
current Message
status string
errText string
width int
height int
}
type rowsMsg struct{ rows []MessageSummary }
type openedMsg struct{ msg Message }
type actionDoneMsg struct{ status string }
type errMsg struct{ err error }
func (m readerModel) Init() tea.Cmd { return m.loadInbox() }
func (m readerModel) loadInbox() tea.Cmd {
return func() tea.Msg {
rows, err := m.c.List(m.ctx, m.mailbox, 0)
if err != nil {
return errMsg{err}
}
return rowsMsg{rows}
}
}
func (m readerModel) open(uid uint32) tea.Cmd {
return func() tea.Msg {
msg, ok, err := m.c.Read(m.ctx, m.mailbox, uid, false)
if err != nil {
return errMsg{err}
}
if !ok {
return errMsg{notFound(m.mailbox, uid)}
}
return openedMsg{msg}
}
}
func (m readerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
case rowsMsg:
m.rows = msg.rows
if m.cursor >= len(m.rows) {
m.cursor = max(0, len(m.rows)-1)
}
m.status = fmt.Sprintf("%s — %d message(s)", m.mailbox, len(m.rows))
case openedMsg:
m.current = msg.msg
m.mode = modeMessage
case actionDoneMsg:
m.status = msg.status
return m, m.loadInbox()
case errMsg:
m.errText = msg.err.Error()
case tea.KeyMsg:
return m.onKey(msg)
}
return m, nil
}
func (m readerModel) onKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
m.errText = ""
if m.mode == modeMessage {
switch k.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "b", "esc", "left", "h":
m.mode = modeList
return m, m.loadInbox()
case "f":
uid := m.current.UID
flagged := !m.current.Flagged
return m, func() tea.Msg {
if err := m.c.Flag(m.ctx, m.mailbox, uid, flagged); err != nil {
return errMsg{err}
}
return actionDoneMsg{status: flagState(flagged)}
}
case "x", "d":
uid := m.current.UID
m.mode = modeList
return m, func() tea.Msg {
if err := m.c.Delete(m.ctx, m.mailbox, uid); err != nil {
return errMsg{err}
}
return actionDoneMsg{status: "deleted"}
}
}
return m, nil
}
switch k.String() {
case "q", "ctrl+c", "esc":
return m, tea.Quit
case "j", "down":
if m.cursor < len(m.rows)-1 {
m.cursor++
}
case "k", "up":
if m.cursor > 0 {
m.cursor--
}
case "r":
m.status = "refreshing…"
return m, m.loadInbox()
case "enter", "l", "right":
if len(m.rows) > 0 {
return m, m.open(m.rows[m.cursor].UID)
}
}
return m, nil
}
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 {
b.WriteString(m.viewMessage())
} else {
b.WriteString(m.viewList())
}
if m.errText != "" {
b.WriteString("\n" + mhErr.Render(m.errText))
}
return mhFrame.Render(b.String())
}
func (m readerModel) viewList() string {
var b strings.Builder
if len(m.rows) == 0 {
b.WriteString(mhDim.Render("(no messages)") + "\n")
}
for i, r := range m.rows {
cur := " "
if i == m.cursor {
cur = mhCursor.Render(" ")
}
marker := " "
if !r.Seen {
marker = "●"
}
flag := " "
if r.Flagged {
flag = mhFlag.Render("⚑")
}
from := r.From.Name
if from == "" {
from = r.From.Address
}
line := fmt.Sprintf("%s%s%s %-22.22s %s", cur, marker, flag, from, r.Subject)
if !r.Seen {
line = mhUnseen.Render(line)
}
b.WriteString(line + "\n")
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"))
return b.String()
}
func (m readerModel) viewMessage() string {
msg := m.current
var b strings.Builder
b.WriteString(mhDim.Render("From: ") + FormatAddress(msg.From) + "\n")
b.WriteString(mhDim.Render("To: ") + joinAddrs(msg.To) + "\n")
if len(msg.CC) > 0 {
b.WriteString(mhDim.Render("Cc: ") + joinAddrs(msg.CC) + "\n")
}
b.WriteString(mhDim.Render("Date: ") + msg.Date.Format("2006-01-02 15:04") + "\n")
b.WriteString(mhDim.Render("Subject: ") + mhUnseen.Render(msg.Subject) + "\n")
if len(msg.Attachments) > 0 {
names := make([]string, len(msg.Attachments))
for i, a := range msg.Attachments {
names[i] = a.Filename
}
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"))
return b.String()
}
func joinAddrs(list []Address) string {
parts := make([]string, len(list))
for i, a := range list {
parts[i] = FormatAddress(a)
}
return strings.Join(parts, ", ")
}
func flagState(on bool) string {
if on {
return "flagged"
}
return "unflagged"
}
func max(a, b int) int {
if a > b {
return a
}
return b
}