Merge remote-tracking branch 'origin/main' into fix/pod-public-html-bind-mount

# Conflicts:
#	cmd/agentbbs/main.go
#	internal/hub/hub.go
#	internal/pods/pods.go
This commit is contained in:
Anthony Ettinger 2026-06-15 14:51:55 +00:00
commit 662250af35
33 changed files with 3127 additions and 42 deletions

View file

@ -66,6 +66,10 @@ var IRCNames = map[string]bool{"irc": true}
// via an in-process newsreader. Free for any registered member, like irc@.
var NewsNames = map[string]bool{"news": true}
// MailNames route a Founding Lifetime (paid) member into the AgentMail client —
// an interactive TUI with a PTY, or a JSON bot mode with a command/no PTY.
var MailNames = map[string]bool{"mail": true}
// GameNames are usernames that route to AgentGames: the line-delimited-JSON
// agent-vs-agent match protocol (PRD §5.2). `play@` stays a guest hub alias.
var GameNames = map[string]bool{"game": true, "games": true}
@ -97,6 +101,16 @@ func IsTorName(u string) bool { return TorNames[strings.ToLower(u)] }
// IsNewsName reports whether the SSH username requests the in-BBS newsreader.
func IsNewsName(u string) bool { return NewsNames[strings.ToLower(u)] }
// IsMailName reports whether the SSH username requests the AgentMail client.
func IsMailName(u string) bool { return MailNames[strings.ToLower(u)] }
// MsgNames route a member-to-member message: `ssh msg@host <user>` leaves a
// note in the recipient's BBS inbox (store-and-forward, see the Members plugin).
var MsgNames = map[string]bool{"msg": true, "message": true}
// IsMsgName reports whether the SSH username requests the messaging route.
func IsMsgName(u string) bool { return MsgNames[strings.ToLower(u)] }
// systemReserved are names that don't drive an SSH route but would still
// collide with a per-user subdomain (<name>.<host>), the agent route, or common
// infra hostnames — so members may not claim them as account names.
@ -113,7 +127,8 @@ var systemReserved = map[string]bool{
func IsReservedName(name string) bool {
n := strings.ToLower(name)
if GuestNames[n] || PodNames[n] || JoinNames[n] || DomainNames[n] || AdminNames[n] ||
TorURLNames[n] || TorIRCNames[n] || TorNames[n] || IRCNames[n] || NewsNames[n] || systemReserved[n] {
TorURLNames[n] || TorIRCNames[n] || TorNames[n] || IRCNames[n] || NewsNames[n] ||
MsgNames[n] || systemReserved[n] {
return true
}
return strings.HasPrefix(n, "video-") // video-<code> call routes

26
internal/brand/banner.go Normal file
View file

@ -0,0 +1,26 @@
// Package brand holds AgentBBS's terminal branding — the ASCII logo shown
// at the top of the join@ onboarding and the ssh <name>@ hub prompts.
// Scaled down from the Profullstack </> mark.
package brand
import "strings"
// logo is the ASCII rendition of the </> brand mark (UTF-8 block glyphs).
const logo = `
▓████▓▒
▒█████▓
+▒▓ +▓████▓+ ▒+
:+▒▓▓▓▓ :▓██▓█▓+ ▒▓▓▓▒+
+▒▒▓▓▓▓▓▓▓ ▓▓▓▓▓▓+ ▒▓▓▓▓▓▒▒+
:+▒▓▓▓▓▓▓▓▒+ ▒▓▓▓▓▓▒ +▒▓▓▓▓▓▓▒▒:
▒▓▓▒▓▒▒▒: +▓▓▓▓▓▓ :▒▒▒▒▒▒▒▒
▒▒▒▒++ ▒▓▓▓▓▓+ ++▒▒▒▒
▒▒+ +▓▓▓▓▓▒ ++▒
▒▒▒▒+ ▒▓▓▓▓▒ +▒▒▒
▒▒▒▒▒▒▒+ ▒▓▓▓▓▒ +▒▒▒▒▒▒▒
+▒▒▒▒▒▒▒▒▒+ +▓▓▓▓▓: +▒▒▒▒▒▒▒▒++
`
// Logo returns the plain (unstyled) multi-line ASCII brand banner, without
// leading or trailing blank lines. The hub/join@ flows apply their own color.
func Logo() string { return strings.Trim(logo, "\n") }

View file

@ -24,7 +24,7 @@ import (
var (
theme = ui.New(ui.Green)
bannerStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Cyan)
bannerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e11d2a"))
)
// SessionApp is a hub entry that takes over the terminal — a pod shell, the IRC

173
internal/mailbox/bot.go Normal file
View file

@ -0,0 +1,173 @@
package mailbox
import (
"context"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
)
// BotUsage is printed when a bot/agent invokes mail with no/unknown command.
const BotUsage = `agentmail (non-interactive mode) — JSON in, JSON out:
mailboxes list folders
list [mailbox] [limit] message summaries (default INBOX)
read <mailbox> <uid> full message (marks seen; "peek" 4th arg to keep unseen)
search <query> [mailbox] search summaries
send read a JSON Draft on stdin, send it
reply <mailbox> <uid> read {"text":...,"replyAll":bool} on stdin
flag <mailbox> <uid> [on|off] set/clear the flagged flag
seen <mailbox> <uid> [on|off] set/clear the seen flag
delete <mailbox> <uid> delete a message
Example: ssh mail@bbs.profullstack.com list INBOX 20`
// RunBot executes one non-interactive command for agents/bots, writing JSON to
// out. It returns an error (also emitted as {"error":...}) on failure.
func RunBot(ctx context.Context, c *Client, args []string, in io.Reader, out io.Writer) error {
if len(args) == 0 {
fmt.Fprintln(out, BotUsage)
return nil
}
enc := json.NewEncoder(out)
enc.SetIndent("", " ")
fail := func(err error) error {
_ = enc.Encode(map[string]string{"error": err.Error()})
return err
}
switch strings.ToLower(args[0]) {
case "mailboxes":
v, err := c.Mailboxes(ctx)
if err != nil {
return fail(err)
}
return enc.Encode(v)
case "list", "ls":
mailbox := Inbox
if len(args) > 1 {
mailbox = args[1]
}
limit := 0
if len(args) > 2 {
limit, _ = strconv.Atoi(args[2])
}
v, err := c.List(ctx, mailbox, limit)
if err != nil {
return fail(err)
}
return enc.Encode(v)
case "read", "show":
mailbox, uid, err := mailboxUID(args)
if err != nil {
return fail(err)
}
peek := len(args) > 3 && strings.EqualFold(args[3], "peek")
msg, ok, err := c.Read(ctx, mailbox, uid, peek)
if err != nil {
return fail(err)
}
if !ok {
return fail(notFound(mailbox, uid))
}
return enc.Encode(msg)
case "search":
if len(args) < 2 {
return fail(fmt.Errorf("usage: search <query> [mailbox]"))
}
mailbox := ""
if len(args) > 2 {
mailbox = args[2]
}
v, err := c.Search(ctx, args[1], mailbox, 0)
if err != nil {
return fail(err)
}
return enc.Encode(v)
case "send":
var d Draft
if err := json.NewDecoder(in).Decode(&d); err != nil {
return fail(fmt.Errorf("send expects a JSON Draft on stdin: %w", err))
}
res, err := c.Send(ctx, d)
if err != nil {
return fail(err)
}
return enc.Encode(res)
case "reply":
mailbox, uid, err := mailboxUID(args)
if err != nil {
return fail(err)
}
var body struct {
Text string `json:"text"`
ReplyAll bool `json:"replyAll"`
}
if err := json.NewDecoder(in).Decode(&body); err != nil {
return fail(fmt.Errorf("reply expects {\"text\":...} on stdin: %w", err))
}
orig, ok, err := c.Read(ctx, mailbox, uid, true)
if err != nil {
return fail(err)
}
if !ok {
return fail(notFound(mailbox, uid))
}
res, err := c.Reply(ctx, orig, body.Text, body.ReplyAll)
if err != nil {
return fail(err)
}
return enc.Encode(res)
case "flag", "seen":
mailbox, uid, err := mailboxUID(args)
if err != nil {
return fail(err)
}
on := true
if len(args) > 3 {
on = !strings.EqualFold(args[3], "off") && args[3] != "false" && args[3] != "0"
}
if args[0] == "flag" {
err = c.Flag(ctx, mailbox, uid, on)
} else {
err = c.MarkSeen(ctx, mailbox, uid, on)
}
if err != nil {
return fail(err)
}
return enc.Encode(map[string]any{"ok": true, "mailbox": mailbox, "uid": uid, args[0]: on})
case "delete", "rm":
mailbox, uid, err := mailboxUID(args)
if err != nil {
return fail(err)
}
if err := c.Delete(ctx, mailbox, uid); err != nil {
return fail(err)
}
return enc.Encode(map[string]any{"ok": true, "deleted": map[string]any{"mailbox": mailbox, "uid": uid}})
default:
fmt.Fprintln(out, BotUsage)
return fmt.Errorf("unknown command: %s", args[0])
}
}
func mailboxUID(args []string) (string, uint32, error) {
if len(args) < 3 {
return "", 0, fmt.Errorf("usage: %s <mailbox> <uid>", args[0])
}
uid, err := strconv.ParseUint(args[2], 10, 32)
if err != nil {
return "", 0, fmt.Errorf("invalid uid %q", args[2])
}
return args[1], uint32(uid), nil
}

172
internal/mailbox/client.go Normal file
View file

@ -0,0 +1,172 @@
package mailbox
import (
"context"
"errors"
"fmt"
"strings"
)
// Identity is the acting member and whether they hold the paid membership.
type Identity struct {
Name string // local-part / handle, e.g. "alice"
Paid bool // Founding Lifetime Member; mail is gated on this
}
// ErrNotPaid is returned to a non-paid member attempting a mail action.
var ErrNotPaid = errors.New("AgentMail is a Founding Lifetime Member feature ($99 one-time) — upgrade: ssh join@bbs.profullstack.com")
// Client is the ergonomic, paid-gated facade the TUI and bot mode use. Every
// method returns plain structs, so the same calls serve humans and agents.
type Client struct {
t Transport
id Identity
domain string
pageSize int
}
// NewClient builds a paid-gated client. domain is the mail domain (e.g.
// mail.profullstack.com); pageSize defaults to 50 when <= 0.
func NewClient(t Transport, id Identity, domain string, pageSize int) *Client {
if pageSize <= 0 {
pageSize = 50
}
return &Client{t: t, id: id, domain: domain, pageSize: pageSize}
}
// Address is the member's own mailbox address, e.g. alice@mail.profullstack.com.
func (c *Client) Address() string { return c.id.Name + "@" + c.domain }
func (c *Client) gate() error {
if c.id.Name == "" || !c.id.Paid {
return ErrNotPaid
}
return nil
}
// Mailboxes lists folders with unread/total counts.
func (c *Client) Mailboxes(ctx context.Context) ([]Mailbox, error) {
if err := c.gate(); err != nil {
return nil, err
}
return c.t.ListMailboxes(ctx)
}
// List returns newest-first summaries for a mailbox (INBOX when empty).
func (c *Client) List(ctx context.Context, mailbox string, limit int) ([]MessageSummary, error) {
if err := c.gate(); err != nil {
return nil, err
}
if mailbox == "" {
mailbox = Inbox
}
if limit <= 0 {
limit = c.pageSize
}
return c.t.ListMessages(ctx, ListOptions{Mailbox: mailbox, Limit: limit})
}
// Read fetches a full message, marking it seen unless peek is true.
func (c *Client) Read(ctx context.Context, mailbox string, uid uint32, peek bool) (Message, bool, error) {
if err := c.gate(); err != nil {
return Message{}, false, err
}
msg, ok, err := c.t.ReadMessage(ctx, mailbox, uid)
if err != nil || !ok {
return msg, ok, err
}
if !peek && !msg.Seen {
seen := true
if err := c.t.SetFlags(ctx, mailbox, uid, FlagChange{Seen: &seen}); err == nil {
msg.Seen = true
}
}
return msg, true, nil
}
// Search runs a free-text search across a mailbox (or all when empty).
func (c *Client) Search(ctx context.Context, query, mailbox string, limit int) ([]MessageSummary, error) {
if err := c.gate(); err != nil {
return nil, err
}
if limit <= 0 {
limit = c.pageSize
}
return c.t.Search(ctx, SearchOptions{Query: query, Mailbox: mailbox, Limit: limit})
}
// Send validates, stamps From, and sends a draft.
func (c *Client) Send(ctx context.Context, d Draft) (SendResult, error) {
if err := c.gate(); err != nil {
return SendResult{}, err
}
norm, err := NormalizeDraft(d)
if err != nil {
return SendResult{}, err
}
return c.t.Send(ctx, c.Address(), norm)
}
// Reply addresses the original sender (and, when replyAll, the other recipients
// minus the member), prefixes "Re:", threads via In-Reply-To, and sends.
func (c *Client) Reply(ctx context.Context, orig Message, text string, replyAll bool) (SendResult, error) {
if err := c.gate(); err != nil {
return SendResult{}, err
}
self := strings.ToLower(c.Address())
to := orig.From
if orig.ReplyTo != nil {
to = *orig.ReplyTo
}
var cc []Address
if replyAll {
for _, a := range append(append([]Address{}, orig.To...), orig.CC...) {
la := strings.ToLower(a.Address)
if la != self && la != strings.ToLower(to.Address) {
cc = append(cc, a)
}
}
}
subject := orig.Subject
if !strings.HasPrefix(strings.ToLower(subject), "re:") {
subject = "Re: " + subject
}
return c.Send(ctx, Draft{To: []Address{to}, CC: cc, Subject: subject, Text: text, InReplyTo: orig.MessageID})
}
// Flag sets or clears the \Flagged flag.
func (c *Client) Flag(ctx context.Context, mailbox string, uid uint32, flagged bool) error {
if err := c.gate(); err != nil {
return err
}
return c.t.SetFlags(ctx, mailbox, uid, FlagChange{Flagged: &flagged})
}
// MarkSeen sets or clears the \Seen flag.
func (c *Client) MarkSeen(ctx context.Context, mailbox string, uid uint32, seen bool) error {
if err := c.gate(); err != nil {
return err
}
return c.t.SetFlags(ctx, mailbox, uid, FlagChange{Seen: &seen})
}
// Delete removes a message.
func (c *Client) Delete(ctx context.Context, mailbox string, uid uint32) error {
if err := c.gate(); err != nil {
return err
}
return c.t.DeleteMessage(ctx, mailbox, uid)
}
// Close releases the underlying transport.
func (c *Client) Close() error {
if c.t == nil {
return nil
}
return c.t.Close()
}
// helper used by bot mode to surface a friendly "not found" message.
func notFound(mailbox string, uid uint32) error {
return fmt.Errorf("%w: %s/%d", ErrNotFound, mailbox, uid)
}

336
internal/mailbox/imap.go Normal file
View file

@ -0,0 +1,336 @@
package mailbox
import (
"bytes"
"context"
"fmt"
"io"
"strings"
"sync"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
gomail "github.com/emersion/go-message/mail"
)
// IMAPConfig connects the adapter to the mail backend (Mailu: Dovecot IMAP +
// Postfix submission). Username/Password are the resolved login (which may be a
// Dovecot master-user login like "alice*gateway").
type IMAPConfig struct {
IMAPAddr string // host:port, e.g. mail.profullstack.com:993 (implicit TLS)
SMTPAddr string // host:port, e.g. smtp.profullstack.com:587 (STARTTLS)
Username string
Password string
// SMTPUser/SMTPPass default to Username/Password when empty.
SMTPUser string
SMTPPass string
}
// imapTransport is a Transport backed by a single authenticated IMAP connection
// plus SMTP submission. Commands are serialized (the IMAP client is not safe for
// concurrent in-flight commands).
type imapTransport struct {
cfg IMAPConfig
mu sync.Mutex
c *imapclient.Client
selected string
}
// NewIMAPTransport dials the IMAP server, logs in, and returns a Transport.
func NewIMAPTransport(cfg IMAPConfig) (Transport, error) {
c, err := imapclient.DialTLS(cfg.IMAPAddr, nil)
if err != nil {
return nil, fmt.Errorf("imap dial %s: %w", cfg.IMAPAddr, err)
}
if err := c.Login(cfg.Username, cfg.Password).Wait(); err != nil {
_ = c.Close()
return nil, fmt.Errorf("imap login: %w", err)
}
return &imapTransport{cfg: cfg, c: c}, nil
}
func (t *imapTransport) Close() error {
t.mu.Lock()
defer t.mu.Unlock()
if t.c == nil {
return nil
}
_ = t.c.Logout().Wait()
return t.c.Close()
}
func (t *imapTransport) selectMailbox(name string, readOnly bool) (*imap.SelectData, error) {
data, err := t.c.Select(name, &imap.SelectOptions{ReadOnly: readOnly}).Wait()
if err != nil {
return nil, fmt.Errorf("select %s: %w", name, err)
}
t.selected = name
return data, nil
}
func (t *imapTransport) ListMailboxes(_ context.Context) ([]Mailbox, error) {
t.mu.Lock()
defer t.mu.Unlock()
entries, err := t.c.List("", "*", nil).Collect()
if err != nil {
return nil, fmt.Errorf("list: %w", err)
}
out := make([]Mailbox, 0, len(entries))
for _, e := range entries {
mb := Mailbox{Name: e.Mailbox, Path: e.Mailbox}
st, err := t.c.Status(e.Mailbox, &imap.StatusOptions{NumMessages: true, NumUnseen: true}).Wait()
if err == nil {
if st.NumMessages != nil {
mb.Total = int(*st.NumMessages)
}
if st.NumUnseen != nil {
mb.Unseen = int(*st.NumUnseen)
}
}
out = append(out, mb)
}
return out, nil
}
func (t *imapTransport) ListMessages(_ context.Context, opts ListOptions) ([]MessageSummary, error) {
t.mu.Lock()
defer t.mu.Unlock()
sel, err := t.selectMailbox(opts.Mailbox, true)
if err != nil {
return nil, err
}
n := sel.NumMessages
if n == 0 {
return nil, nil
}
start := uint32(1)
if opts.Limit > 0 && n > uint32(opts.Limit) {
start = n - uint32(opts.Limit) + 1
}
seq := imap.SeqSet(nil)
seq.AddRange(start, n)
bufs, err := t.c.Fetch(seq, &imap.FetchOptions{Envelope: true, Flags: true, UID: true}).Collect()
if err != nil {
return nil, fmt.Errorf("fetch: %w", err)
}
rows := make([]MessageSummary, 0, len(bufs))
for _, b := range bufs {
rows = append(rows, summaryFromBuf(opts.Mailbox, b))
}
// newest first
for i, j := 0, len(rows)-1; i < j; i, j = i+1, j-1 {
rows[i], rows[j] = rows[j], rows[i]
}
return rows, nil
}
func (t *imapTransport) ReadMessage(_ context.Context, mailbox string, uid uint32) (Message, bool, error) {
t.mu.Lock()
defer t.mu.Unlock()
if _, err := t.selectMailbox(mailbox, false); err != nil {
return Message{}, false, err
}
set := imap.UIDSetNum(imap.UID(uid))
section := &imap.FetchItemBodySection{}
bufs, err := t.c.Fetch(set, &imap.FetchOptions{
Envelope: true,
Flags: true,
UID: true,
BodySection: []*imap.FetchItemBodySection{section},
}).Collect()
if err != nil {
return Message{}, false, fmt.Errorf("fetch uid %d: %w", uid, err)
}
if len(bufs) == 0 {
return Message{}, false, nil
}
b := bufs[0]
msg := Message{MessageSummary: summaryFromBuf(mailbox, b)}
raw := b.FindBodySection(section)
if len(raw) > 0 {
fillBody(&msg, raw)
}
msg.Snippet = Snippet(msg.Text, 140)
return msg, true, nil
}
func (t *imapTransport) Search(_ context.Context, opts SearchOptions) ([]MessageSummary, error) {
t.mu.Lock()
defer t.mu.Unlock()
mailbox := opts.Mailbox
if mailbox == "" {
mailbox = Inbox
}
if _, err := t.selectMailbox(mailbox, true); err != nil {
return nil, err
}
data, err := t.c.UIDSearch(&imap.SearchCriteria{Text: []string{opts.Query}}, nil).Wait()
if err != nil {
return nil, fmt.Errorf("search: %w", err)
}
uids := data.AllUIDs()
if len(uids) == 0 {
return nil, nil
}
if opts.Limit > 0 && len(uids) > opts.Limit {
uids = uids[len(uids)-opts.Limit:]
}
bufs, err := t.c.Fetch(imap.UIDSetNum(uids...), &imap.FetchOptions{Envelope: true, Flags: true, UID: true}).Collect()
if err != nil {
return nil, fmt.Errorf("search fetch: %w", err)
}
rows := make([]MessageSummary, 0, len(bufs))
for _, b := range bufs {
rows = append(rows, summaryFromBuf(mailbox, b))
}
return rows, nil
}
func (t *imapTransport) Send(_ context.Context, from string, d Draft) (SendResult, error) {
msg, msgID := buildRFC822(from, d)
// SMTPUser may be empty for a trusted local relay (no AUTH).
if err := smtpSend(t.cfg.SMTPAddr, t.cfg.SMTPUser, t.cfg.SMTPPass, from, recipients(d), msg); err != nil {
return SendResult{}, fmt.Errorf("smtp send: %w", err)
}
// Best-effort copy to Sent so the message shows in the member's mailbox.
t.mu.Lock()
_, _ = t.c.Append(Sent, int64(len(msg)), nil).Wait() // ignore APPEND errors
t.mu.Unlock()
return SendResult{MessageID: msgID}, nil
}
func (t *imapTransport) SetFlags(_ context.Context, mailbox string, uid uint32, fc FlagChange) error {
t.mu.Lock()
defer t.mu.Unlock()
if _, err := t.selectMailbox(mailbox, false); err != nil {
return err
}
set := imap.UIDSetNum(imap.UID(uid))
apply := func(flag imap.Flag, on bool) error {
op := imap.StoreFlagsDel
if on {
op = imap.StoreFlagsAdd
}
return t.c.Store(set, &imap.StoreFlags{Op: op, Flags: []imap.Flag{flag}, Silent: true}, nil).Close()
}
if fc.Seen != nil {
if err := apply(imap.FlagSeen, *fc.Seen); err != nil {
return fmt.Errorf("store seen: %w", err)
}
}
if fc.Flagged != nil {
if err := apply(imap.FlagFlagged, *fc.Flagged); err != nil {
return fmt.Errorf("store flagged: %w", err)
}
}
return nil
}
func (t *imapTransport) DeleteMessage(_ context.Context, mailbox string, uid uint32) error {
t.mu.Lock()
defer t.mu.Unlock()
if _, err := t.selectMailbox(mailbox, false); err != nil {
return err
}
set := imap.UIDSetNum(imap.UID(uid))
if err := t.c.Store(set, &imap.StoreFlags{Op: imap.StoreFlagsAdd, Flags: []imap.Flag{imap.FlagDeleted}, Silent: true}, nil).Close(); err != nil {
return fmt.Errorf("store deleted: %w", err)
}
if err := t.c.Expunge().Close(); err != nil {
return fmt.Errorf("expunge: %w", err)
}
return nil
}
func summaryFromBuf(mailbox string, b *imapclient.FetchMessageBuffer) MessageSummary {
s := MessageSummary{Mailbox: mailbox, UID: uint32(b.UID)}
if b.Envelope != nil {
env := b.Envelope
s.Subject = env.Subject
s.Date = env.Date
s.From = firstAddr(env.From)
s.To = addrs(env.To)
}
for _, f := range b.Flags {
switch f {
case imap.FlagSeen:
s.Seen = true
case imap.FlagFlagged:
s.Flagged = true
}
}
return s
}
func firstAddr(list []imap.Address) Address {
if len(list) == 0 {
return Address{}
}
return imapAddr(list[0])
}
func addrs(list []imap.Address) []Address {
out := make([]Address, 0, len(list))
for _, a := range list {
out = append(out, imapAddr(a))
}
return out
}
func imapAddr(a imap.Address) Address {
addr := a.Mailbox
if a.Host != "" {
addr = a.Mailbox + "@" + a.Host
}
return Address{Name: a.Name, Address: addr}
}
// fillBody parses the raw RFC822 message into the text/html body + attachment
// metadata using go-message's mail reader.
func fillBody(msg *Message, raw []byte) {
mr, err := gomail.CreateReader(bytes.NewReader(raw))
if err != nil {
// Not MIME we can parse — keep the raw text after the header break.
msg.Text = rawBody(raw)
return
}
if env := mr.Header; env.Get("Message-Id") != "" {
msg.MessageID = env.Get("Message-Id")
}
for {
p, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
break
}
switch h := p.Header.(type) {
case *gomail.InlineHeader:
body, _ := io.ReadAll(p.Body)
ct, _, _ := h.ContentType()
if strings.EqualFold(ct, "text/html") {
msg.HTML = string(body)
} else if msg.Text == "" {
msg.Text = string(body)
}
case *gomail.AttachmentHeader:
name, _ := h.Filename()
ct, _, _ := h.ContentType()
body, _ := io.ReadAll(p.Body)
msg.Attachments = append(msg.Attachments, Attachment{Filename: name, ContentType: ct, Size: len(body)})
}
}
msg.HasAttachments = len(msg.Attachments) > 0
}
// rawBody returns the body after the first blank line of a raw RFC822 message.
func rawBody(raw []byte) string {
if i := bytes.Index(raw, []byte("\r\n\r\n")); i >= 0 {
return string(raw[i+4:])
}
if i := bytes.Index(raw, []byte("\n\n")); i >= 0 {
return string(raw[i+2:])
}
return string(raw)
}

View file

@ -0,0 +1,144 @@
package mailbox
import (
"context"
"errors"
"testing"
"time"
)
func seeded() *MemoryTransport {
m := NewMemoryTransport()
m.Add(Message{MessageSummary: MessageSummary{UID: 1, Mailbox: Inbox, From: Address{Name: "Carol", Address: "carol@example.com"}, Subject: "Welcome", Date: time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)}, Text: "hi alice"})
m.Add(Message{MessageSummary: MessageSummary{UID: 2, Mailbox: Inbox, From: Address{Address: "deploy@ci.example.com"}, Subject: "Build passed", Date: time.Date(2026, 6, 2, 10, 0, 0, 0, time.UTC)}, Text: "all green"})
return m
}
func paidClient(t Transport) *Client {
return NewClient(t, Identity{Name: "alice", Paid: true}, "mail.profullstack.com", 50)
}
func TestParseFormatAddress(t *testing.T) {
a := ParseAddress("Ada Lovelace <ada@x.com>")
if a.Name != "Ada Lovelace" || a.Address != "ada@x.com" {
t.Fatalf("parse named: %+v", a)
}
if got := ParseAddress("ada@x.com"); got.Address != "ada@x.com" || got.Name != "" {
t.Fatalf("parse bare: %+v", got)
}
if got := FormatAddress(Address{Name: "Doe, John", Address: "j@x.com"}); got != `"Doe, John" <j@x.com>` {
t.Fatalf("format specials: %q", got)
}
}
func TestValidEmailAndDraft(t *testing.T) {
if !ValidEmail("a@b.com") || ValidEmail("nope") {
t.Fatal("ValidEmail")
}
if _, err := NormalizeDraft(Draft{Subject: "x", Text: "y"}); err == nil {
t.Fatal("expected error for no recipients")
}
d, err := NormalizeDraft(Draft{To: []Address{{Address: " a@b.com "}}, Subject: " hi ", Text: "z"})
if err != nil || len(d.To) != 1 || d.To[0].Address != "a@b.com" || d.Subject != "hi" {
t.Fatalf("normalize: %+v err=%v", d, err)
}
}
func TestGate(t *testing.T) {
c := NewClient(seeded(), Identity{Name: "bob", Paid: false}, "mail.profullstack.com", 0)
if _, err := c.Inbox(context.Background(), 0); !errors.Is(err, ErrNotPaid) {
t.Fatalf("expected ErrNotPaid, got %v", err)
}
}
// Inbox is a thin helper used in tests and bot mode.
func (c *Client) Inbox(ctx context.Context, limit int) ([]MessageSummary, error) {
return c.List(ctx, Inbox, limit)
}
func TestListNewestFirst(t *testing.T) {
c := paidClient(seeded())
got, err := c.List(context.Background(), Inbox, 0)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0].UID != 2 {
t.Fatalf("expected newest first [2,1], got %+v", got)
}
}
func TestReadMarksSeenAndPeek(t *testing.T) {
tr := seeded()
c := paidClient(tr)
if _, ok, _ := c.Read(context.Background(), Inbox, 1, false); !ok {
t.Fatal("read 1")
}
if m, _, _ := tr.ReadMessage(context.Background(), Inbox, 1); !m.Seen {
t.Fatal("uid 1 should be seen")
}
if _, _, _ = c.Read(context.Background(), Inbox, 2, true); true {
if m, _, _ := tr.ReadMessage(context.Background(), Inbox, 2); m.Seen {
t.Fatal("peek must not mark seen")
}
}
if _, ok, _ := c.Read(context.Background(), Inbox, 999, false); ok {
t.Fatal("unknown uid should be ok=false")
}
}
func TestSearch(t *testing.T) {
c := paidClient(seeded())
hits, _ := c.Search(context.Background(), "green", "", 0)
if len(hits) != 1 || hits[0].UID != 2 {
t.Fatalf("search green: %+v", hits)
}
hits, _ = c.Search(context.Background(), "carol@example.com", "", 0)
if len(hits) != 1 || hits[0].UID != 1 {
t.Fatalf("search sender: %+v", hits)
}
}
func TestSendAndReply(t *testing.T) {
tr := seeded()
c := paidClient(tr)
res, err := c.Send(context.Background(), Draft{To: []Address{{Address: "carol@example.com"}}, Subject: " Hi ", Text: "yo"})
if err != nil || res.MessageID == "" {
t.Fatalf("send: %v", err)
}
sent, _ := tr.ListMessages(context.Background(), ListOptions{Mailbox: Sent})
if len(sent) != 1 || sent[0].From.Address != "alice@mail.profullstack.com" || sent[0].Subject != "Hi" {
t.Fatalf("sent: %+v", sent)
}
orig, _, _ := c.Read(context.Background(), Inbox, 1, true)
if _, err := c.Reply(context.Background(), orig, "thanks", false); err != nil {
t.Fatal(err)
}
sent, _ = tr.ListMessages(context.Background(), ListOptions{Mailbox: Sent})
var re MessageSummary
for _, s := range sent {
if s.Subject == "Re: Welcome" {
re = s
}
}
if re.Subject != "Re: Welcome" || re.To[0].Address != "carol@example.com" {
t.Fatalf("reply: %+v", sent)
}
}
func TestFlagAndDelete(t *testing.T) {
tr := seeded()
c := paidClient(tr)
if err := c.Flag(context.Background(), Inbox, 1, true); err != nil {
t.Fatal(err)
}
if m, _, _ := tr.ReadMessage(context.Background(), Inbox, 1); !m.Flagged {
t.Fatal("uid 1 should be flagged")
}
if err := c.Delete(context.Background(), Inbox, 1); err != nil {
t.Fatal(err)
}
if _, ok, _ := tr.ReadMessage(context.Background(), Inbox, 1); ok {
t.Fatal("uid 1 should be deleted")
}
}

186
internal/mailbox/memory.go Normal file
View file

@ -0,0 +1,186 @@
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)
}

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
}

81
internal/mailbox/smtp.go Normal file
View file

@ -0,0 +1,81 @@
package mailbox
import (
"bytes"
"fmt"
"mime"
"net"
"net/smtp"
"strings"
"time"
)
// buildRFC822 renders a draft as an RFC 5322 message (CRLF line endings, UTF-8
// text body). It returns the bytes and the generated Message-ID.
func buildRFC822(from string, d Draft) ([]byte, string) {
domain := "localhost"
if at := strings.LastIndexByte(from, '@'); at >= 0 {
domain = from[at+1:]
}
msgID := fmt.Sprintf("<%s@%s>", randToken(), domain)
var b bytes.Buffer
wh := func(k, v string) { fmt.Fprintf(&b, "%s: %s\r\n", k, v) }
wh("From", from)
wh("To", headerAddrs(d.To))
if len(d.CC) > 0 {
wh("Cc", headerAddrs(d.CC))
}
wh("Subject", mime.QEncoding.Encode("utf-8", d.Subject))
wh("Date", time.Now().Format(time.RFC1123Z))
wh("Message-Id", msgID)
if d.InReplyTo != "" {
wh("In-Reply-To", d.InReplyTo)
wh("References", d.InReplyTo)
}
wh("MIME-Version", "1.0")
wh("Content-Type", "text/plain; charset=utf-8")
wh("Content-Transfer-Encoding", "8bit")
b.WriteString("\r\n")
b.WriteString(strings.ReplaceAll(d.Text, "\n", "\r\n"))
return b.Bytes(), msgID
}
// headerAddrs renders an address list for a header, encoding display names.
func headerAddrs(list []Address) string {
parts := make([]string, len(list))
for i, a := range list {
if a.Name == "" {
parts[i] = a.Address
} else {
parts[i] = fmt.Sprintf("%s <%s>", mime.QEncoding.Encode("utf-8", a.Name), a.Address)
}
}
return strings.Join(parts, ", ")
}
// recipients flattens To+Cc+Bcc into envelope recipient addresses.
func recipients(d Draft) []string {
var out []string
for _, l := range [][]Address{d.To, d.CC, d.BCC} {
for _, a := range l {
out = append(out, a.Address)
}
}
return out
}
// smtpSend submits a built message via SMTP. With a non-empty user it does
// STARTTLS + AUTH (e.g. smtp.profullstack.com:587); with an empty user it sends
// unauthenticated, for a trusted local relay (e.g. the co-located Postfix).
func smtpSend(addr, user, pass, from string, rcpts []string, msg []byte) error {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("smtp addr %q: %w", addr, err)
}
var auth smtp.Auth
if user != "" {
auth = smtp.PlainAuth("", user, pass, host)
}
return smtp.SendMail(addr, auth, from, rcpts, msg)
}

View file

@ -0,0 +1,100 @@
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
}

99
internal/mailbox/types.go Normal file
View file

@ -0,0 +1,99 @@
// Package mailbox is the BBS-side mail client for Founding Lifetime members: a
// transport-agnostic core (read, search, compose, send, flag, delete) with a
// Bubble Tea TUI for humans and a line-oriented JSON mode for agents/bots. It
// talks to the self-hosted Mailu stack (Dovecot IMAP + Postfix submission) at
// mail.profullstack.com / smtp.profullstack.com.
//
// The TS counterpart is @logicsrc/plugin-agentmail; the domain shapes here are
// deliberately the same so tooling can move between them.
package mailbox
import "time"
// Address is a parsed mail address.
type Address struct {
Name string `json:"name,omitempty"`
Address string `json:"address"`
}
// Mailbox is an IMAP folder with unread/total counts.
type Mailbox struct {
Name string `json:"name"`
Path string `json:"path"`
Unseen int `json:"unseen"`
Total int `json:"total"`
}
// MessageSummary is a lightweight row for list/search views.
type MessageSummary struct {
UID uint32 `json:"uid"`
Mailbox string `json:"mailbox"`
From Address `json:"from"`
To []Address `json:"to"`
Subject string `json:"subject"`
Date time.Time `json:"date"`
Seen bool `json:"seen"`
Flagged bool `json:"flagged"`
HasAttachments bool `json:"hasAttachments"`
Snippet string `json:"snippet"`
}
// Attachment is attachment metadata (bytes fetched separately).
type Attachment struct {
Filename string `json:"filename"`
ContentType string `json:"contentType"`
Size int `json:"size"`
}
// Message is a fully fetched message.
type Message struct {
MessageSummary
CC []Address `json:"cc,omitempty"`
ReplyTo *Address `json:"replyTo,omitempty"`
MessageID string `json:"messageId"`
References []string `json:"references,omitempty"`
Text string `json:"text"`
HTML string `json:"html,omitempty"`
Attachments []Attachment `json:"attachments,omitempty"`
}
// Draft is an outgoing message.
type Draft struct {
To []Address `json:"to"`
CC []Address `json:"cc,omitempty"`
BCC []Address `json:"bcc,omitempty"`
Subject string `json:"subject"`
Text string `json:"text"`
InReplyTo string `json:"inReplyTo,omitempty"`
}
// ListOptions controls a mailbox listing.
type ListOptions struct {
Mailbox string
Limit int
}
// SearchOptions controls a search.
type SearchOptions struct {
Mailbox string // empty = all mailboxes
Query string
Limit int
}
// FlagChange is a partial flag update; nil fields are left unchanged.
type FlagChange struct {
Seen *bool
Flagged *bool
}
// SendResult reports a sent message's id.
type SendResult struct {
MessageID string `json:"messageId"`
}
const (
// Inbox is the default mailbox.
Inbox = "INBOX"
// Sent is where sent mail is stored.
Sent = "Sent"
)

View file

@ -0,0 +1,10 @@
package nntpd
import "testing"
func TestParseRangeSingleArticle(t *testing.T) {
low, high := parseRange("5")
if low != 5 || high != 5 {
t.Fatalf(`parseRange("5") = %d, %d; want 5, 5`, low, high)
}
}

View file

@ -142,12 +142,16 @@ func (s *Server) Process(nc net.Conn) {
if err != nil {
return
}
cmd := strings.Split(l, " ")
args := []string{}
if len(cmd) > 1 {
args = cmd[1:]
fields := strings.Fields(l)
if len(fields) == 0 {
err = handleDefault(nil, sess, c)
} else {
args := []string{}
if len(fields) > 1 {
args = fields[1:]
}
err = sess.dispatchCommand(fields[0], args, c)
}
err = sess.dispatchCommand(cmd[0], args, c)
if err != nil {
if _, isNNTPError := err.(*NNTPError); err == io.EOF {
return
@ -170,8 +174,9 @@ func parseRange(spec string) (low, high int64) {
h, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
h = math.MaxInt64
return 0, h
}
return 0, h
return h, h
}
l, _ := strconv.ParseInt(parts[0], 10, 64)
h, err := strconv.ParseInt(parts[1], 10, 64)
@ -357,6 +362,9 @@ func handleIHave(args []string, s *session, c *textproto.Conn) error {
if !s.backend.AllowPost() {
return ErrNotWanted
}
if len(args) < 1 {
return ErrSyntax
}
article, err := s.backend.GetArticle(nil, args[0])
if article != nil {
return ErrNotWanted

View file

@ -0,0 +1,129 @@
package nntpd
import (
"errors"
"net"
"net/textproto"
"strings"
"testing"
"time"
"github.com/dustin/go-nntp"
)
type whitespaceBackend struct {
group *nntp.Group
allowPost bool
}
func (b *whitespaceBackend) ListGroups(max int) ([]*nntp.Group, error) {
return []*nntp.Group{b.group}, nil
}
func (b *whitespaceBackend) GetGroup(name string) (*nntp.Group, error) {
if name == b.group.Name {
return b.group, nil
}
return nil, ErrNoSuchGroup
}
func (b *whitespaceBackend) GetArticle(group *nntp.Group, id string) (*nntp.Article, error) {
return nil, ErrInvalidArticleNumber
}
func (b *whitespaceBackend) GetArticles(group *nntp.Group, from, to int64) ([]NumberedArticle, error) {
return nil, nil
}
func (b *whitespaceBackend) Authorized() bool { return true }
func (b *whitespaceBackend) Authenticate(user, pass string) (Backend, error) {
return b, nil
}
func (b *whitespaceBackend) AllowPost() bool { return b.allowPost }
func (b *whitespaceBackend) Post(article *nntp.Article) error {
return errors.New("posting disabled")
}
func TestProcessCollapsesRepeatedCommandWhitespace(t *testing.T) {
backend := &whitespaceBackend{
group: &nntp.Group{Name: "pfs.general", Count: 1, Low: 1, High: 1, Posting: nntp.PostingPermitted},
}
server := NewServer(backend)
clientConn, serverConn := net.Pipe()
defer clientConn.Close()
done := make(chan struct{})
go func() {
server.Process(serverConn)
close(done)
}()
client := textproto.NewConn(clientConn)
defer client.Close()
if line, err := client.ReadLine(); err != nil || !strings.HasPrefix(line, "200 ") {
t.Fatalf("greeting = %q, %v", line, err)
}
if err := client.PrintfLine("GROUP pfs.general"); err != nil {
t.Fatalf("send GROUP: %v", err)
}
if line, err := client.ReadLine(); err != nil || !strings.HasPrefix(line, "211 ") {
t.Fatalf("GROUP with repeated spaces = %q, %v; want 211", line, err)
}
if err := client.PrintfLine("QUIT"); err != nil {
t.Fatalf("send QUIT: %v", err)
}
if line, err := client.ReadLine(); err != nil || !strings.HasPrefix(line, "205 ") {
t.Fatalf("QUIT = %q, %v; want 205", line, err)
}
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("server did not close after QUIT")
}
}
func TestIHaveWithoutMessageIDReturnsSyntaxError(t *testing.T) {
backend := &whitespaceBackend{
group: &nntp.Group{Name: "pfs.general", Posting: nntp.PostingPermitted},
allowPost: true,
}
server := NewServer(backend)
clientConn, serverConn := net.Pipe()
defer clientConn.Close()
done := make(chan struct{})
go func() {
server.Process(serverConn)
close(done)
}()
client := textproto.NewConn(clientConn)
defer client.Close()
if line, err := client.ReadLine(); err != nil || !strings.HasPrefix(line, "200 ") {
t.Fatalf("greeting = %q, %v", line, err)
}
if err := client.PrintfLine("IHAVE"); err != nil {
t.Fatalf("send IHAVE: %v", err)
}
if line, err := client.ReadLine(); err != nil || !strings.HasPrefix(line, "501 ") {
t.Fatalf("IHAVE without message-id = %q, %v; want 501", line, err)
}
if err := client.PrintfLine("QUIT"); err != nil {
t.Fatalf("send QUIT: %v", err)
}
if line, err := client.ReadLine(); err != nil || !strings.HasPrefix(line, "205 ") {
t.Fatalf("QUIT = %q, %v; want 205", line, err)
}
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("server did not close after QUIT")
}
}

View file

@ -22,6 +22,9 @@ type Context struct {
DataDir string
// AssetsDir is the read-only platform assets tree (wads, binaries).
AssetsDir string
// Host is the BBS hostname (e.g. bbs.profullstack.com), for building
// member homepage URLs (https://Host/~name) and similar links.
Host string
}
// Plugin is the only integration point between a feature and the hub.

View file

@ -70,6 +70,23 @@ func (m *Manager) publicHTMLMount(user string) (host, spec string) {
return host, host + ":/home/dev/public_html"
}
// hasMount reports whether the named container already has a mount at the given
// destination path. Used to detect pods created before a mount was introduced so
// ensure can recreate them. A failed inspect reports false (treat as missing).
func (m *Manager) hasMount(name, dest string) bool {
out, err := exec.Command(m.engine, "container", "inspect",
"-f", "{{range .Mounts}}{{println .Destination}}{{end}}", name).Output()
if err != nil {
return false
}
for _, line := range strings.Split(string(out), "\n") {
if strings.TrimSpace(line) == dest {
return true
}
}
return false
}
// Engine reports the active container engine.
func (m *Manager) Engine() string { return m.engine }
@ -106,8 +123,21 @@ func (m *Manager) ensure(user string) (string, error) {
}
// Already exists?
if err := exec.Command(m.engine, "container", "inspect", name).Run(); err == nil {
_ = exec.Command(m.engine, "start", name).Run() // no-op if running
return name, nil
// Self-heal pods created before the homepage bind existed: recreate so
// ~/public_html maps to the served dir. The named home volume persists
// across rm, so the member's files are kept. Only heal when the pod is
// idle (no live session) — never pull a running pod out from under an
// active session; a still-unbound pod heals on its next idle attach.
m.mu.Lock()
idle := m.attached[name] == 0
m.mu.Unlock()
if pubSpec != "" && idle && !m.hasMount(name, "/home/dev/public_html") {
_ = exec.Command(m.engine, "rm", "-f", name).Run() // fall through to recreate with the bind
} else {
_ = exec.Command(m.engine, "start", name).Run() // no-op if running
m.tuneApt(name)
return name, nil
}
}
args := []string{
"run", "-d",
@ -148,9 +178,28 @@ func (m *Manager) ensure(user string) (string, error) {
if err != nil {
return "", fmt.Errorf("pods: create failed: %v: %s", err, strings.TrimSpace(string(out)))
}
m.tuneApt(name)
return name, nil
}
// tuneApt makes apt usable inside the hardened pod. apt drops privileges to
// the _apt user for downloads (setgroups/setegid/seteuid), which needs
// CAP_SETUID/CAP_SETGID/CAP_CHOWN — caps we intentionally drop (cap-drop ALL).
// Rather than re-grant those to the whole container, disable apt's download
// sandbox so package management runs as the pod's (rootless-mapped) root.
//
// Only applies to the podman/container-root path; under docker the pod runs as
// uid 1000 and can't write /etc/apt (apt isn't usable there by design). Failure
// is non-fatal: a missing config just means the user sees the old apt errors.
func (m *Manager) tuneApt(name string) {
if m.engine == "docker" {
return
}
_ = exec.Command(m.engine, "exec", "--user", "root", name,
"sh", "-c", `printf 'APT::Sandbox::User "root";\n' > /etc/apt/apt.conf.d/00no-sandbox`,
).Run()
}
// Attach provisions the pod and wires the SSH session to a shell inside it.
// Blocks until the shell exits or the session closes.
func (m *Manager) Attach(s ssh.Session, user string) error {

View file

@ -148,8 +148,16 @@ func TestTamperedTokenFails(t *testing.T) {
t.Fatal("original token failed to verify")
}
// Tampering with the signature segment must also fail.
badSig := parts[0] + "." + parts[1] + "." + flipLastChar(parts[2])
// Tampering with the signature segment must also fail. Corrupt a DECODED
// signature byte (not a base64 char): flipping the last base64 char can land
// on the unused trailing bits of a 64-byte signature, which decode back to
// the same bytes and still verify — that made this test flaky.
sigBytes, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil || len(sigBytes) == 0 {
t.Fatalf("decode signature segment: %v", err)
}
sigBytes[0] ^= 0xFF
badSig := parts[0] + "." + parts[1] + "." + base64.RawURLEncoding.EncodeToString(sigBytes)
if verifySig(badSig, pub) {
t.Fatal("token with corrupted signature verified but should have failed")
}
@ -168,20 +176,6 @@ func verifySig(token string, pub ed25519.PublicKey) bool {
return ed25519.Verify(pub, []byte(parts[0]+"."+parts[1]), sig)
}
func flipLastChar(s string) string {
if s == "" {
return s
}
b := []byte(s)
last := b[len(b)-1]
if last == 'A' {
b[len(b)-1] = 'B'
} else {
b[len(b)-1] = 'A'
}
return string(b)
}
func TestParsePrivateKeyAcceptsSeedAndFull(t *testing.T) {
_, priv, err := ed25519.GenerateKey(nil)
if err != nil {

View file

@ -5,6 +5,7 @@ package store
import (
"database/sql"
"errors"
"strings"
"time"
_ "modernc.org/sqlite"
@ -45,6 +46,16 @@ func scanUser(sc interface{ Scan(...any) error }) (User, error) {
return u, nil
}
// Message is one member-to-member note in the store-and-forward inbox.
type Message struct {
ID int64
From string
To string
Body string
Read bool
At time.Time
}
// Score is one leaderboard entry.
type Score struct {
User string
@ -100,6 +111,21 @@ type Store interface {
AddChat(userID int64, username, role, text string) error
RecentChats(username string, n int) ([]ChatMessage, error)
// Member-to-member messaging (store-and-forward inbox).
// SendMessage leaves a note from→to in the recipient's inbox.
SendMessage(from, to, body string) error
// Inbox returns up to n messages addressed to username, newest first.
Inbox(username string, n int) ([]Message, error)
// UnreadCount reports how many unread messages username has waiting.
UnreadCount(username string) (int, error)
// MarkRead marks the given message ids read (scoped to username so a member
// can only clear their own mail). Empty ids is a no-op.
MarkRead(username string, ids []int64) error
// OnlineUsers reports the set of usernames with an open session (no
// ended_at), for the members directory presence dots.
OnlineUsers() (map[string]bool, error)
// Custom domains mapped to a member's homepage (public_html).
// MapDomain binds domain→username, returning ErrDomainTaken if it is
// already claimed by someone else (re-binding to the same owner is a no-op).
@ -471,6 +497,15 @@ CREATE TABLE IF NOT EXISTS news_articles (
);
CREATE INDEX IF NOT EXISTS idx_news_articles_grp ON news_articles(grp, num);
CREATE INDEX IF NOT EXISTS idx_news_articles_msgid ON news_articles(msg_id);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY,
from_user TEXT NOT NULL,
to_user TEXT NOT NULL,
body TEXT NOT NULL,
read INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_messages_to ON messages(to_user, id DESC);
`
func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) {
@ -686,6 +721,75 @@ func (s *sqliteStore) RecentChats(username string, n int) ([]ChatMessage, error)
return out, rows.Err()
}
func (s *sqliteStore) SendMessage(from, to, body string) error {
_, err := s.db.Exec(`INSERT INTO messages (from_user, to_user, body) VALUES (?,?,?)`,
from, to, body)
return err
}
func (s *sqliteStore) Inbox(username string, n int) ([]Message, error) {
if n <= 0 {
n = 50
}
rows, err := s.db.Query(`
SELECT id, from_user, to_user, body, read, created_at
FROM messages WHERE to_user = ? ORDER BY id DESC LIMIT ?`, username, n)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Message
for rows.Next() {
var m Message
var read int
var at string
if err := rows.Scan(&m.ID, &m.From, &m.To, &m.Body, &read, &at); err != nil {
return nil, err
}
m.Read = read != 0
m.At, _ = time.Parse(time.RFC3339, at)
out = append(out, m)
}
return out, rows.Err()
}
func (s *sqliteStore) UnreadCount(username string) (int, error) {
var n int
err := s.db.QueryRow(`SELECT COUNT(*) FROM messages WHERE to_user = ? AND read = 0`, username).Scan(&n)
return n, err
}
func (s *sqliteStore) MarkRead(username string, ids []int64) error {
if len(ids) == 0 {
return nil
}
q := `UPDATE messages SET read = 1 WHERE to_user = ? AND id IN (?` + strings.Repeat(",?", len(ids)-1) + `)`
args := make([]any, 0, len(ids)+1)
args = append(args, username)
for _, id := range ids {
args = append(args, id)
}
_, err := s.db.Exec(q, args...)
return err
}
func (s *sqliteStore) OnlineUsers() (map[string]bool, error) {
rows, err := s.db.Query(`SELECT DISTINCT username FROM sessions WHERE ended_at IS NULL`)
if err != nil {
return nil, err
}
defer rows.Close()
online := map[string]bool{}
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
online[strings.ToLower(name)] = true
}
return online, rows.Err()
}
func (s *sqliteStore) MapDomain(domain, username string) error {
var owner string
err := s.db.QueryRow(`SELECT username FROM domains WHERE domain = ?`, domain).Scan(&owner)

View file

@ -0,0 +1,82 @@
package store
import "testing"
func TestMessagingRoundtrip(t *testing.T) {
st := openTest(t)
_, _ = st.EnsureUser("alice", "member", "SHA256:aaa")
_, _ = st.EnsureUser("bob", "member", "SHA256:bbb")
if n, err := st.UnreadCount("bob"); err != nil || n != 0 {
t.Fatalf("fresh unread: n=%d err=%v", n, err)
}
if err := st.SendMessage("alice", "bob", "hey, c4 tonight?"); err != nil {
t.Fatalf("send: %v", err)
}
if err := st.SendMessage("alice", "bob", "second note"); err != nil {
t.Fatalf("send2: %v", err)
}
n, err := st.UnreadCount("bob")
if err != nil || n != 2 {
t.Fatalf("unread after send: n=%d err=%v", n, err)
}
inbox, err := st.Inbox("bob", 10)
if err != nil {
t.Fatalf("inbox: %v", err)
}
if len(inbox) != 2 {
t.Fatalf("want 2 messages, got %d", len(inbox))
}
// Newest first.
if inbox[0].Body != "second note" || inbox[0].From != "alice" || inbox[0].To != "bob" {
t.Fatalf("unexpected newest message: %+v", inbox[0])
}
// Mark only the first read; the other stays unread.
if err := st.MarkRead("bob", []int64{inbox[0].ID}); err != nil {
t.Fatalf("markread: %v", err)
}
if n, _ := st.UnreadCount("bob"); n != 1 {
t.Fatalf("want 1 unread after partial read, got %d", n)
}
// MarkRead is scoped to the recipient: alice can't clear bob's mail.
if err := st.MarkRead("alice", []int64{inbox[1].ID}); err != nil {
t.Fatalf("markread other: %v", err)
}
if n, _ := st.UnreadCount("bob"); n != 1 {
t.Fatalf("cross-user markread leaked: unread=%d", n)
}
// Empty ids is a no-op.
if err := st.MarkRead("bob", nil); err != nil {
t.Fatalf("markread empty: %v", err)
}
}
func TestOnlineUsers(t *testing.T) {
st := openTest(t)
u, _ := st.EnsureUser("carol", "member", "SHA256:ccc")
id, _ := st.RecordSession(u.ID, "carol", "1.2.3.4", "hub")
online, err := st.OnlineUsers()
if err != nil {
t.Fatalf("online: %v", err)
}
if !online["carol"] {
t.Fatal("carol should be online while her session is open")
}
if err := st.EndSession(id); err != nil {
t.Fatalf("end: %v", err)
}
online, _ = st.OnlineUsers()
if online["carol"] {
t.Fatal("carol should be offline after her session ends")
}
}