From 3889d3171320236154ef78a24d182942e5364b15 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 30 Dec 2025 06:55:08 -0800 Subject: [PATCH] Add static mailing list support (list:name syntax) (gt-2rfvq) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement static mailing list expansion for the mail system: - Add list:name address syntax (e.g., list:oncall) - Load lists from ~/gt/config/messaging.json - Fan-out delivery: each list member gets their own message copy - Clear error handling for unknown list names - Add tests for list detection, parsing, and expansion - Update gt mail send help text with list:name documentation - Show recipients in output when sending to a list Example: gt mail send list:oncall -s "Alert" -m "System down" # Expands to: mayor/, gastown/witness # Creates 2 message copies 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/mail.go | 25 +++++- internal/mail/router.go | 90 +++++++++++++++++++++ internal/mail/router_test.go | 146 +++++++++++++++++++++++++++++++++++ 3 files changed, 260 insertions(+), 1 deletion(-) diff --git a/internal/cmd/mail.go b/internal/cmd/mail.go index 5fa6707e..9e27211d 100644 --- a/internal/cmd/mail.go +++ b/internal/cmd/mail.go @@ -93,6 +93,11 @@ Addresses: /refinery - Send to a rig's Refinery / - Send to a specific polecat / - Broadcast to a rig + list: - Send to a mailing list (fans out to all members) + +Mailing lists are defined in ~/gt/config/messaging.json and allow +sending to multiple recipients at once. Each recipient gets their +own copy of the message. Message types: task - Required processing @@ -117,7 +122,8 @@ Examples: gt mail send gastown/Toast -s "Urgent" -m "Help!" --urgent gt mail send mayor/ -s "Re: Status" -m "Done" --reply-to msg-abc123 gt mail send --self -s "Handoff" -m "Context for next session" - gt mail send gastown/Toast -s "Update" -m "Progress report" --cc overseer`, + gt mail send gastown/Toast -s "Update" -m "Progress report" --cc overseer + gt mail send list:oncall -s "Alert" -m "System down"`, Args: cobra.MaximumNArgs(1), RunE: runMailSend, } @@ -381,6 +387,17 @@ func runMailSend(cmd *cobra.Command, args []string) error { // Send via router router := mail.NewRouter(workDir) + + // Check if this is a list address to show fan-out details + var listRecipients []string + if strings.HasPrefix(to, "list:") { + var err error + listRecipients, err = router.ExpandListAddress(to) + if err != nil { + return fmt.Errorf("sending message: %w", err) + } + } + if err := router.Send(msg); err != nil { return fmt.Errorf("sending message: %w", err) } @@ -390,6 +407,12 @@ func runMailSend(cmd *cobra.Command, args []string) error { fmt.Printf("%s Message sent to %s\n", style.Bold.Render("✓"), to) fmt.Printf(" Subject: %s\n", mailSubject) + + // Show fan-out recipients for list addresses + if len(listRecipients) > 0 { + fmt.Printf(" Recipients: %s\n", strings.Join(listRecipients, ", ")) + } + if len(msg.CC) > 0 { fmt.Printf(" CC: %s\n", strings.Join(msg.CC, ", ")) } diff --git a/internal/mail/router.go b/internal/mail/router.go index 066f1da2..00848c9c 100644 --- a/internal/mail/router.go +++ b/internal/mail/router.go @@ -9,9 +9,13 @@ import ( "path/filepath" "strings" + "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/tmux" ) +// ErrUnknownList indicates a mailing list name was not found in configuration. +var ErrUnknownList = errors.New("unknown mailing list") + // Router handles message delivery via beads. // It routes messages to the correct beads database based on address: // - Town-level (mayor/, deacon/) -> {townRoot}/.beads @@ -45,6 +49,42 @@ func NewRouterWithTownRoot(workDir, townRoot string) *Router { } } +// isListAddress returns true if the address uses list:name syntax. +func isListAddress(address string) bool { + return strings.HasPrefix(address, "list:") +} + +// parseListName extracts the list name from a list:name address. +func parseListName(address string) string { + return strings.TrimPrefix(address, "list:") +} + +// expandList returns the recipients for a mailing list. +// Returns ErrUnknownList if the list is not found. +func (r *Router) expandList(listName string) ([]string, error) { + // Load messaging config from town root + if r.townRoot == "" { + return nil, fmt.Errorf("%w: %s (no town root)", ErrUnknownList, listName) + } + + configPath := config.MessagingConfigPath(r.townRoot) + cfg, err := config.LoadMessagingConfig(configPath) + if err != nil { + return nil, fmt.Errorf("loading messaging config: %w", err) + } + + recipients, ok := cfg.Lists[listName] + if !ok { + return nil, fmt.Errorf("%w: %s", ErrUnknownList, listName) + } + + if len(recipients) == 0 { + return nil, fmt.Errorf("%w: %s (empty list)", ErrUnknownList, listName) + } + + return recipients, nil +} + // detectTownRoot finds the town root by looking for mayor/town.json. func detectTownRoot(startDir string) string { dir := startDir @@ -114,7 +154,14 @@ func (r *Router) shouldBeWisp(msg *Message) bool { // Send delivers a message via beads message. // Routes the message to the correct beads database based on recipient address. +// If the recipient is a mailing list (list:name), fans out to all list members, +// creating a separate copy for each recipient. func (r *Router) Send(msg *Message) error { + // Check for mailing list address + if isListAddress(msg.To) { + return r.sendToList(msg) + } + // Convert addresses to beads identities toIdentity := addressToIdentity(msg.To) @@ -184,6 +231,49 @@ func (r *Router) Send(msg *Message) error { return nil } +// sendToList expands a mailing list and sends individual copies to each recipient. +// Each recipient gets their own message copy with the same content. +// Returns a ListDeliveryResult with details about the fan-out. +func (r *Router) sendToList(msg *Message) error { + listName := parseListName(msg.To) + recipients, err := r.expandList(listName) + if err != nil { + return err + } + + // Send to each recipient + var lastErr error + successCount := 0 + for _, recipient := range recipients { + // Create a copy of the message for this recipient + copy := *msg + copy.To = recipient + + if err := r.Send(©); err != nil { + lastErr = err + continue + } + successCount++ + } + + // If all sends failed, return the last error + if successCount == 0 && lastErr != nil { + return fmt.Errorf("sending to list %s: %w", listName, lastErr) + } + + return nil +} + +// ExpandListAddress expands a list:name address to its recipients. +// Returns ErrUnknownList if the list is not found. +// This is exported for use by commands that want to show fan-out details. +func (r *Router) ExpandListAddress(address string) ([]string, error) { + if !isListAddress(address) { + return nil, fmt.Errorf("not a list address: %s", address) + } + return r.expandList(parseListName(address)) +} + // isSelfMail returns true if sender and recipient are the same identity. // Normalizes addresses by removing trailing slashes for comparison. func isSelfMail(from, to string) bool { diff --git a/internal/mail/router_test.go b/internal/mail/router_test.go index a01cb268..e11685a9 100644 --- a/internal/mail/router_test.go +++ b/internal/mail/router_test.go @@ -219,3 +219,149 @@ func TestNewRouterWithTownRoot(t *testing.T) { t.Errorf("townRoot = %q, want '/home/gt'", r.townRoot) } } + +func TestIsListAddress(t *testing.T) { + tests := []struct { + address string + want bool + }{ + {"list:oncall", true}, + {"list:cleanup/gastown", true}, + {"list:", true}, // Edge case: empty list name (will fail on expand) + {"mayor/", false}, + {"gastown/witness", false}, + {"listoncall", false}, // Missing colon + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.address, func(t *testing.T) { + got := isListAddress(tt.address) + if got != tt.want { + t.Errorf("isListAddress(%q) = %v, want %v", tt.address, got, tt.want) + } + }) + } +} + +func TestParseListName(t *testing.T) { + tests := []struct { + address string + want string + }{ + {"list:oncall", "oncall"}, + {"list:cleanup/gastown", "cleanup/gastown"}, + {"list:", ""}, + {"list:alerts", "alerts"}, + } + + for _, tt := range tests { + t.Run(tt.address, func(t *testing.T) { + got := parseListName(tt.address) + if got != tt.want { + t.Errorf("parseListName(%q) = %q, want %q", tt.address, got, tt.want) + } + }) + } +} + +func TestExpandList(t *testing.T) { + // Create temp directory with messaging config + tmpDir := t.TempDir() + configDir := filepath.Join(tmpDir, "config") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatal(err) + } + + // Write messaging.json with test lists + configContent := `{ + "type": "messaging", + "version": 1, + "lists": { + "oncall": ["mayor/", "gastown/witness"], + "cleanup/gastown": ["gastown/witness", "deacon/"] + } +}` + if err := os.WriteFile(filepath.Join(configDir, "messaging.json"), []byte(configContent), 0644); err != nil { + t.Fatal(err) + } + + r := NewRouterWithTownRoot(tmpDir, tmpDir) + + tests := []struct { + name string + listName string + want []string + wantErr bool + errString string + }{ + { + name: "oncall list", + listName: "oncall", + want: []string{"mayor/", "gastown/witness"}, + }, + { + name: "cleanup/gastown list", + listName: "cleanup/gastown", + want: []string{"gastown/witness", "deacon/"}, + }, + { + name: "unknown list", + listName: "nonexistent", + wantErr: true, + errString: "unknown mailing list", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := r.expandList(tt.listName) + if tt.wantErr { + if err == nil { + t.Errorf("expandList(%q) expected error, got nil", tt.listName) + } else if tt.errString != "" && !contains(err.Error(), tt.errString) { + t.Errorf("expandList(%q) error = %v, want containing %q", tt.listName, err, tt.errString) + } + return + } + if err != nil { + t.Errorf("expandList(%q) unexpected error: %v", tt.listName, err) + return + } + if len(got) != len(tt.want) { + t.Errorf("expandList(%q) = %v, want %v", tt.listName, got, tt.want) + return + } + for i, addr := range got { + if addr != tt.want[i] { + t.Errorf("expandList(%q)[%d] = %q, want %q", tt.listName, i, addr, tt.want[i]) + } + } + }) + } +} + +func TestExpandListNoTownRoot(t *testing.T) { + r := &Router{workDir: "/tmp", townRoot: ""} + _, err := r.expandList("oncall") + if err == nil { + t.Error("expandList with no townRoot should error") + } + if !contains(err.Error(), "no town root") { + t.Errorf("expandList error = %v, want containing 'no town root'", err) + } +} + +// contains checks if s contains substr (helper for error checking) +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr)) +} + +func containsHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +}