agentbbs/internal/mailbox/transport.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

100 lines
3.2 KiB
Go

package mailbox
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
)
// Transport is the low-level mailbox backend. The TUI, the bot mode, and the
// Client all talk to this interface, so an in-memory fake (tests/dev) and the
// real IMAP/SMTP adapter are interchangeable.
type Transport interface {
ListMailboxes(ctx context.Context) ([]Mailbox, error)
ListMessages(ctx context.Context, opts ListOptions) ([]MessageSummary, error)
// ReadMessage returns (Message, false, nil) when the uid is unknown.
ReadMessage(ctx context.Context, mailbox string, uid uint32) (Message, bool, error)
Search(ctx context.Context, opts SearchOptions) ([]MessageSummary, error)
// Send delivers an already-validated draft, stamped from `from`.
Send(ctx context.Context, from string, d Draft) (SendResult, error)
SetFlags(ctx context.Context, mailbox string, uid uint32, fc FlagChange) error
DeleteMessage(ctx context.Context, mailbox string, uid uint32) error
Close() error
}
// ErrNotFound is returned when a uid/mailbox cannot be resolved.
var ErrNotFound = errors.New("mailbox: message not found")
var addrRe = regexp.MustCompile(`^(.*)<([^>]+)>\s*$`)
// ParseAddress parses "Name <a@b>" or a bare "a@b".
func ParseAddress(raw string) Address {
s := strings.TrimSpace(raw)
if m := addrRe.FindStringSubmatch(s); m != nil {
name := strings.Trim(strings.TrimSpace(m[1]), `"`)
return Address{Name: strings.TrimSpace(name), Address: strings.TrimSpace(m[2])}
}
return Address{Address: strings.Trim(s, "<>")}
}
// FormatAddress renders an Address back to "Name <addr>" (or just the address).
func FormatAddress(a Address) string {
if a.Name == "" {
return a.Address
}
if strings.ContainsAny(a.Name, `",<>@`) {
return fmt.Sprintf("%q <%s>", a.Name, a.Address)
}
return fmt.Sprintf("%s <%s>", a.Name, a.Address)
}
// Snippet collapses whitespace and truncates to at most max runes.
func Snippet(body string, max int) string {
flat := strings.Join(strings.Fields(body), " ")
if max <= 0 || len([]rune(flat)) <= max {
return flat
}
r := []rune(flat)
return string(r[:max-1]) + "…"
}
// ValidEmail is a loose RFC5322-ish check: one @, a dotted domain, no spaces.
func ValidEmail(v string) bool {
v = strings.TrimSpace(v)
if len(v) < 3 || len(v) > 254 || strings.ContainsAny(v, " \t\r\n") {
return false
}
at := strings.LastIndexByte(v, '@')
if at <= 0 || at == len(v)-1 {
return false
}
return strings.Contains(v[at+1:], ".")
}
// NormalizeDraft trims the subject, drops empty recipient lists, and rejects a
// draft with no valid recipient.
func NormalizeDraft(d Draft) (Draft, error) {
clean := func(in []Address) []Address {
out := make([]Address, 0, len(in))
for _, a := range in {
a.Address = strings.TrimSpace(a.Address)
if a.Address != "" {
out = append(out, a)
}
}
return out
}
to, cc, bcc := clean(d.To), clean(d.CC), clean(d.BCC)
all := append(append(append([]Address{}, to...), cc...), bcc...)
if len(all) == 0 {
return Draft{}, errors.New("a draft needs at least one recipient")
}
for _, a := range all {
if !ValidEmail(a.Address) {
return Draft{}, fmt.Errorf("invalid recipient address: %s", a.Address)
}
}
return Draft{To: to, CC: cc, BCC: bcc, Subject: strings.TrimSpace(d.Subject), Text: d.Text, InReplyTo: d.InReplyTo}, nil
}