mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
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>
251 lines
6 KiB
Go
251 lines
6 KiB
Go
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
|
||
}
|