mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
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:
parent
6b4db2b934
commit
e165ecee26
13 changed files with 1660 additions and 0 deletions
251
internal/mailbox/reader_tui.go
Normal file
251
internal/mailbox/reader_tui.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue