feat: add mail system and CLI commands

- internal/mail: Message types with priority support
- internal/mail: Mailbox JSONL operations (list, get, append, delete)
- internal/mail: Router for address resolution and delivery
- gt mail send: Send messages to agents
- gt mail inbox: List messages (--unread, --json)
- gt mail read: Read and mark messages as read
- Address formats: mayor/, rig/, rig/polecat, rig/refinery
- High priority messages trigger tmux notification
- Auto-detect sender from GT_RIG/GT_POLECAT env vars

Closes gt-u1j.6, gt-u1j.12

Generated with Claude Code

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-16 14:19:15 -08:00
parent d3d929105e
commit e984a55fe5
4 changed files with 710 additions and 0 deletions

222
internal/mail/mailbox.go Normal file
View File

@@ -0,0 +1,222 @@
package mail
import (
"bufio"
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
)
// Common errors
var (
ErrMessageNotFound = errors.New("message not found")
ErrEmptyInbox = errors.New("inbox is empty")
)
// Mailbox manages a JSONL-based inbox.
type Mailbox struct {
path string
}
// NewMailbox creates a mailbox at the given path.
func NewMailbox(path string) *Mailbox {
return &Mailbox{path: path}
}
// Path returns the mailbox file path.
func (m *Mailbox) Path() string {
return m.path
}
// List returns all messages in the mailbox.
func (m *Mailbox) List() ([]*Message, error) {
file, err := os.Open(m.path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
defer file.Close()
var messages []*Message
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
var msg Message
if err := json.Unmarshal([]byte(line), &msg); err != nil {
continue // Skip malformed lines
}
messages = append(messages, &msg)
}
if err := scanner.Err(); err != nil {
return nil, err
}
// Sort by timestamp (newest first)
sort.Slice(messages, func(i, j int) bool {
return messages[i].Timestamp.After(messages[j].Timestamp)
})
return messages, nil
}
// ListUnread returns unread messages.
func (m *Mailbox) ListUnread() ([]*Message, error) {
all, err := m.List()
if err != nil {
return nil, err
}
var unread []*Message
for _, msg := range all {
if !msg.Read {
unread = append(unread, msg)
}
}
return unread, nil
}
// Get returns a message by ID.
func (m *Mailbox) Get(id string) (*Message, error) {
messages, err := m.List()
if err != nil {
return nil, err
}
for _, msg := range messages {
if msg.ID == id {
return msg, nil
}
}
return nil, ErrMessageNotFound
}
// Append adds a message to the mailbox.
func (m *Mailbox) Append(msg *Message) error {
// Ensure directory exists
dir := filepath.Dir(m.path)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
// Open for append
file, err := os.OpenFile(m.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer file.Close()
data, err := json.Marshal(msg)
if err != nil {
return err
}
_, err = file.WriteString(string(data) + "\n")
return err
}
// MarkRead marks a message as read.
func (m *Mailbox) MarkRead(id string) error {
messages, err := m.List()
if err != nil {
return err
}
found := false
for _, msg := range messages {
if msg.ID == id {
msg.Read = true
found = true
}
}
if !found {
return ErrMessageNotFound
}
return m.rewrite(messages)
}
// Delete removes a message from the mailbox.
func (m *Mailbox) Delete(id string) error {
messages, err := m.List()
if err != nil {
return err
}
var filtered []*Message
found := false
for _, msg := range messages {
if msg.ID == id {
found = true
} else {
filtered = append(filtered, msg)
}
}
if !found {
return ErrMessageNotFound
}
return m.rewrite(filtered)
}
// Count returns the total and unread message counts.
func (m *Mailbox) Count() (total, unread int, err error) {
messages, err := m.List()
if err != nil {
return 0, 0, err
}
total = len(messages)
for _, msg := range messages {
if !msg.Read {
unread++
}
}
return total, unread, nil
}
// rewrite rewrites the mailbox with the given messages.
func (m *Mailbox) rewrite(messages []*Message) error {
// Sort by timestamp (oldest first for JSONL)
sort.Slice(messages, func(i, j int) bool {
return messages[i].Timestamp.Before(messages[j].Timestamp)
})
// Write to temp file
tmpPath := m.path + ".tmp"
file, err := os.Create(tmpPath)
if err != nil {
return err
}
for _, msg := range messages {
data, err := json.Marshal(msg)
if err != nil {
file.Close()
os.Remove(tmpPath)
return err
}
file.WriteString(string(data) + "\n")
}
if err := file.Close(); err != nil {
os.Remove(tmpPath)
return err
}
// Atomic rename
return os.Rename(tmpPath, m.path)
}

136
internal/mail/router.go Normal file
View File

@@ -0,0 +1,136 @@
package mail
import (
"fmt"
"path/filepath"
"strings"
"github.com/steveyegge/gastown/internal/tmux"
)
// Router handles message delivery and address resolution.
type Router struct {
townRoot string
tmux *tmux.Tmux
}
// NewRouter creates a new mail router.
func NewRouter(townRoot string) *Router {
return &Router{
townRoot: townRoot,
tmux: tmux.NewTmux(),
}
}
// Send delivers a message to its recipient.
func (r *Router) Send(msg *Message) error {
// Resolve recipient mailbox path
mailboxPath, err := r.ResolveMailbox(msg.To)
if err != nil {
return fmt.Errorf("resolving address '%s': %w", msg.To, err)
}
// Append to mailbox
mailbox := NewMailbox(mailboxPath)
if err := mailbox.Append(msg); err != nil {
return fmt.Errorf("delivering message: %w", err)
}
// Optionally notify if recipient is a polecat with active session
if isPolecat(msg.To) && msg.Priority == PriorityHigh {
r.notifyPolecat(msg)
}
return nil
}
// ResolveMailbox converts an address to a mailbox file path.
//
// Address formats:
// - mayor/ → <town>/mayor/mail/inbox.jsonl
// - <rig>/refinery → <town>/<rig>/refinery/mail/inbox.jsonl
// - <rig>/<polecat> → <town>/<rig>/polecats/<polecat>/mail/inbox.jsonl
// - <rig>/ → <town>/<rig>/mail/inbox.jsonl (rig broadcast)
func (r *Router) ResolveMailbox(address string) (string, error) {
address = strings.TrimSpace(address)
if address == "" {
return "", fmt.Errorf("empty address")
}
// Mayor
if address == "mayor/" || address == "mayor" {
return filepath.Join(r.townRoot, "mayor", "mail", "inbox.jsonl"), nil
}
// Parse rig/target
parts := strings.SplitN(address, "/", 2)
if len(parts) < 2 {
return "", fmt.Errorf("invalid address format: %s", address)
}
rig := parts[0]
target := parts[1]
// Rig broadcast (empty target or just /)
if target == "" {
return filepath.Join(r.townRoot, rig, "mail", "inbox.jsonl"), nil
}
// Refinery
if target == "refinery" {
return filepath.Join(r.townRoot, rig, "refinery", "mail", "inbox.jsonl"), nil
}
// Polecat
return filepath.Join(r.townRoot, rig, "polecats", target, "mail", "inbox.jsonl"), nil
}
// GetMailbox returns a Mailbox for the given address.
func (r *Router) GetMailbox(address string) (*Mailbox, error) {
path, err := r.ResolveMailbox(address)
if err != nil {
return nil, err
}
return NewMailbox(path), nil
}
// notifyPolecat sends a notification to a polecat's tmux session.
func (r *Router) notifyPolecat(msg *Message) error {
// Parse rig/polecat from address
parts := strings.SplitN(msg.To, "/", 2)
if len(parts) != 2 {
return nil
}
rig := parts[0]
polecat := parts[1]
// Generate session name (matches session.Manager)
sessionID := fmt.Sprintf("gt-%s-%s", rig, polecat)
// Check if session exists
hasSession, err := r.tmux.HasSession(sessionID)
if err != nil || !hasSession {
return nil // No active session, skip notification
}
// Inject notification
notification := fmt.Sprintf("[MAIL] %s", msg.Subject)
return r.tmux.SendKeys(sessionID, notification)
}
// isPolecat checks if an address points to a polecat.
func isPolecat(address string) bool {
// Not mayor, not refinery, has rig/name format
if strings.HasPrefix(address, "mayor") {
return false
}
parts := strings.SplitN(address, "/", 2)
if len(parts) != 2 {
return false
}
target := parts[1]
return target != "" && target != "refinery"
}

67
internal/mail/types.go Normal file
View File

@@ -0,0 +1,67 @@
// Package mail provides JSONL-based messaging for agent communication.
package mail
import (
"crypto/rand"
"encoding/hex"
"time"
)
// Priority levels for messages.
type Priority string
const (
// PriorityNormal is the default priority.
PriorityNormal Priority = "normal"
// PriorityHigh indicates an urgent message.
PriorityHigh Priority = "high"
)
// Message represents a mail message between agents.
type Message struct {
// ID is a unique message identifier.
ID string `json:"id"`
// From is the sender address (e.g., "gastown/Toast" or "mayor/").
From string `json:"from"`
// To is the recipient address.
To string `json:"to"`
// Subject is a brief summary.
Subject string `json:"subject"`
// Body is the full message content.
Body string `json:"body"`
// Timestamp is when the message was sent.
Timestamp time.Time `json:"timestamp"`
// Read indicates if the message has been read.
Read bool `json:"read"`
// Priority is the message priority.
Priority Priority `json:"priority"`
}
// NewMessage creates a new message with a generated ID.
func NewMessage(from, to, subject, body string) *Message {
return &Message{
ID: generateID(),
From: from,
To: to,
Subject: subject,
Body: body,
Timestamp: time.Now(),
Read: false,
Priority: PriorityNormal,
}
}
// generateID creates a random message ID.
func generateID() string {
b := make([]byte, 8)
rand.Read(b)
return "msg-" + hex.EncodeToString(b)
}