Add static mailing list support (list:name syntax) (gt-2rfvq)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -93,6 +93,11 @@ Addresses:
|
|||||||
<rig>/refinery - Send to a rig's Refinery
|
<rig>/refinery - Send to a rig's Refinery
|
||||||
<rig>/<polecat> - Send to a specific polecat
|
<rig>/<polecat> - Send to a specific polecat
|
||||||
<rig>/ - Broadcast to a rig
|
<rig>/ - Broadcast to a rig
|
||||||
|
list:<name> - 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:
|
Message types:
|
||||||
task - Required processing
|
task - Required processing
|
||||||
@@ -117,7 +122,8 @@ Examples:
|
|||||||
gt mail send gastown/Toast -s "Urgent" -m "Help!" --urgent
|
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 mayor/ -s "Re: Status" -m "Done" --reply-to msg-abc123
|
||||||
gt mail send --self -s "Handoff" -m "Context for next session"
|
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),
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: runMailSend,
|
RunE: runMailSend,
|
||||||
}
|
}
|
||||||
@@ -381,6 +387,17 @@ func runMailSend(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
// Send via router
|
// Send via router
|
||||||
router := mail.NewRouter(workDir)
|
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 {
|
if err := router.Send(msg); err != nil {
|
||||||
return fmt.Errorf("sending message: %w", err)
|
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("%s Message sent to %s\n", style.Bold.Render("✓"), to)
|
||||||
fmt.Printf(" Subject: %s\n", mailSubject)
|
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 {
|
if len(msg.CC) > 0 {
|
||||||
fmt.Printf(" CC: %s\n", strings.Join(msg.CC, ", "))
|
fmt.Printf(" CC: %s\n", strings.Join(msg.CC, ", "))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,13 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"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.
|
// Router handles message delivery via beads.
|
||||||
// It routes messages to the correct beads database based on address:
|
// It routes messages to the correct beads database based on address:
|
||||||
// - Town-level (mayor/, deacon/) -> {townRoot}/.beads
|
// - 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.
|
// detectTownRoot finds the town root by looking for mayor/town.json.
|
||||||
func detectTownRoot(startDir string) string {
|
func detectTownRoot(startDir string) string {
|
||||||
dir := startDir
|
dir := startDir
|
||||||
@@ -114,7 +154,14 @@ func (r *Router) shouldBeWisp(msg *Message) bool {
|
|||||||
|
|
||||||
// Send delivers a message via beads message.
|
// Send delivers a message via beads message.
|
||||||
// Routes the message to the correct beads database based on recipient address.
|
// 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 {
|
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
|
// Convert addresses to beads identities
|
||||||
toIdentity := addressToIdentity(msg.To)
|
toIdentity := addressToIdentity(msg.To)
|
||||||
|
|
||||||
@@ -184,6 +231,49 @@ func (r *Router) Send(msg *Message) error {
|
|||||||
return nil
|
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.
|
// isSelfMail returns true if sender and recipient are the same identity.
|
||||||
// Normalizes addresses by removing trailing slashes for comparison.
|
// Normalizes addresses by removing trailing slashes for comparison.
|
||||||
func isSelfMail(from, to string) bool {
|
func isSelfMail(from, to string) bool {
|
||||||
|
|||||||
@@ -219,3 +219,149 @@ func TestNewRouterWithTownRoot(t *testing.T) {
|
|||||||
t.Errorf("townRoot = %q, want '/home/gt'", r.townRoot)
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user