agentbbs/internal/mailbox/memory.go
Anthony Ettinger e165ecee26 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>
2026-06-14 16:49:04 +00:00

186 lines
4.7 KiB
Go

package mailbox
import (
"context"
"fmt"
"math/rand"
"sort"
"strings"
"sync"
"time"
)
// MemoryTransport is a complete, dependency-free Transport for tests, local
// development, and as the reference for what the IMAP/SMTP adapter must do.
type MemoryTransport struct {
mu sync.Mutex
byMailbox map[string][]Message
nextUID uint32
}
// NewMemoryTransport returns an empty in-memory transport.
func NewMemoryTransport() *MemoryTransport {
return &MemoryTransport{byMailbox: map[string][]Message{}, nextUID: 1}
}
// Add inserts a message, filling defaults, and returns the stored copy.
func (m *MemoryTransport) Add(msg Message) Message {
m.mu.Lock()
defer m.mu.Unlock()
if msg.UID == 0 {
msg.UID = m.nextUID
}
if msg.UID >= m.nextUID {
m.nextUID = msg.UID + 1
}
if msg.Date.IsZero() {
msg.Date = time.Now().UTC()
}
if msg.MessageID == "" {
msg.MessageID = fmt.Sprintf("<%s@memory.local>", randToken())
}
if msg.Snippet == "" {
msg.Snippet = Snippet(msg.Text, 140)
}
msg.HasAttachments = len(msg.Attachments) > 0
m.byMailbox[msg.Mailbox] = append(m.byMailbox[msg.Mailbox], msg)
return msg
}
func (m *MemoryTransport) ListMailboxes(_ context.Context) ([]Mailbox, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]Mailbox, 0, len(m.byMailbox))
for path, list := range m.byMailbox {
unseen := 0
for _, msg := range list {
if !msg.Seen {
unseen++
}
}
out = append(out, Mailbox{Name: path, Path: path, Total: len(list), Unseen: unseen})
}
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
return out, nil
}
func (m *MemoryTransport) ListMessages(_ context.Context, opts ListOptions) ([]MessageSummary, error) {
m.mu.Lock()
defer m.mu.Unlock()
list := append([]Message{}, m.byMailbox[opts.Mailbox]...)
sort.Slice(list, func(i, j int) bool { return list[i].Date.After(list[j].Date) })
if opts.Limit > 0 && len(list) > opts.Limit {
list = list[:opts.Limit]
}
return summaries(list), nil
}
func (m *MemoryTransport) ReadMessage(_ context.Context, mailbox string, uid uint32) (Message, bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
for _, msg := range m.byMailbox[mailbox] {
if msg.UID == uid {
return msg, true, nil
}
}
return Message{}, false, nil
}
func (m *MemoryTransport) Search(_ context.Context, opts SearchOptions) ([]MessageSummary, error) {
m.mu.Lock()
defer m.mu.Unlock()
q := strings.ToLower(opts.Query)
var hits []Message
boxes := []string{opts.Mailbox}
if opts.Mailbox == "" {
boxes = boxes[:0]
for b := range m.byMailbox {
boxes = append(boxes, b)
}
}
for _, b := range boxes {
for _, msg := range m.byMailbox[b] {
hay := strings.ToLower(msg.Subject + " " + msg.From.Address + " " + msg.From.Name + " " + msg.Text)
if strings.Contains(hay, q) {
hits = append(hits, msg)
}
}
}
sort.Slice(hits, func(i, j int) bool { return hits[i].Date.After(hits[j].Date) })
if opts.Limit > 0 && len(hits) > opts.Limit {
hits = hits[:opts.Limit]
}
return summaries(hits), nil
}
func (m *MemoryTransport) Send(_ context.Context, from string, d Draft) (SendResult, error) {
domain := "memory.local"
if at := strings.LastIndexByte(from, '@'); at >= 0 {
domain = from[at+1:]
}
id := fmt.Sprintf("<%s@%s>", randToken(), domain)
var refs []string
if d.InReplyTo != "" {
refs = []string{d.InReplyTo}
}
m.Add(Message{
MessageSummary: MessageSummary{Mailbox: Sent, From: Address{Address: from}, To: d.To, Subject: d.Subject, Seen: true},
CC: d.CC,
Text: d.Text,
MessageID: id,
References: refs,
})
return SendResult{MessageID: id}, nil
}
func (m *MemoryTransport) SetFlags(_ context.Context, mailbox string, uid uint32, fc FlagChange) error {
m.mu.Lock()
defer m.mu.Unlock()
list := m.byMailbox[mailbox]
for i := range list {
if list[i].UID == uid {
if fc.Seen != nil {
list[i].Seen = *fc.Seen
}
if fc.Flagged != nil {
list[i].Flagged = *fc.Flagged
}
return nil
}
}
return ErrNotFound
}
func (m *MemoryTransport) DeleteMessage(_ context.Context, mailbox string, uid uint32) error {
m.mu.Lock()
defer m.mu.Unlock()
list := m.byMailbox[mailbox]
for i := range list {
if list[i].UID == uid {
m.byMailbox[mailbox] = append(list[:i], list[i+1:]...)
return nil
}
}
return ErrNotFound
}
func (m *MemoryTransport) Close() error { return nil }
func summaries(list []Message) []MessageSummary {
out := make([]MessageSummary, len(list))
for i, msg := range list {
s := msg.MessageSummary
s.HasAttachments = len(msg.Attachments) > 0
out[i] = s
}
return out
}
func randToken() string {
const alpha = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, 10)
for i := range b {
b[i] = alpha[rand.Intn(len(alpha))]
}
return string(b)
}