Files
gastown/internal/cmd/mail_send.go
gastown/crew/max 42999d883d feat(mail): update mail send to use address resolver
Integrate the new address resolver into gt mail send:
- Resolves addresses to determine delivery mode (agent, queue, channel)
- Queue/channel: single message delivery
- Agent/group/pattern: fan-out to all resolved recipients
- Falls back to legacy routing if resolver fails
- Shows resolved recipients when fan-out occurs

Supports all new address types:
- Direct: gastown/crew/max
- Patterns: */witness, gastown/*
- Groups: @ops-team (beads-native groups)
- Queues: queue:work-requests
- Channels: channel:alerts

Part of gt-xfqh1e.10 (mail send update task).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 21:19:54 -08:00

191 lines
5.0 KiB
Go

package cmd
import (
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/events"
"github.com/steveyegge/gastown/internal/mail"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace"
)
func runMailSend(cmd *cobra.Command, args []string) error {
var to string
if mailSendSelf {
// Auto-detect identity from cwd
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("getting current directory: %w", err)
}
townRoot, err := workspace.FindFromCwd()
if err != nil || townRoot == "" {
return fmt.Errorf("not in a Gas Town workspace")
}
roleInfo, err := GetRoleWithContext(cwd, townRoot)
if err != nil {
return fmt.Errorf("detecting role: %w", err)
}
ctx := RoleContext{
Role: roleInfo.Role,
Rig: roleInfo.Rig,
Polecat: roleInfo.Polecat,
TownRoot: townRoot,
WorkDir: cwd,
}
to = buildAgentIdentity(ctx)
if to == "" {
return fmt.Errorf("cannot determine identity (role: %s)", ctx.Role)
}
} else if len(args) > 0 {
to = args[0]
} else {
return fmt.Errorf("address required (or use --self)")
}
// All mail uses town beads (two-level architecture)
workDir, err := findMailWorkDir()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Determine sender
from := detectSender()
// Create message
msg := &mail.Message{
From: from,
To: to,
Subject: mailSubject,
Body: mailBody,
}
// Set priority (--urgent overrides --priority)
if mailUrgent {
msg.Priority = mail.PriorityUrgent
} else {
msg.Priority = mail.PriorityFromInt(mailPriority)
}
if mailNotify && msg.Priority == mail.PriorityNormal {
msg.Priority = mail.PriorityHigh
}
// Set message type
msg.Type = mail.ParseMessageType(mailType)
// Set pinned flag
msg.Pinned = mailPinned
// Set wisp flag (ephemeral message) - default true, --permanent overrides
msg.Wisp = mailWisp && !mailPermanent
// Set CC recipients
msg.CC = mailCC
// Handle reply-to: auto-set type to reply and look up thread
if mailReplyTo != "" {
msg.ReplyTo = mailReplyTo
if msg.Type == mail.TypeNotification {
msg.Type = mail.TypeReply
}
// Look up original message to get thread ID
router := mail.NewRouter(workDir)
mailbox, err := router.GetMailbox(from)
if err == nil {
if original, err := mailbox.Get(mailReplyTo); err == nil {
msg.ThreadID = original.ThreadID
}
}
}
// Generate thread ID for new threads
if msg.ThreadID == "" {
msg.ThreadID = generateThreadID()
}
// Use address resolver for new address types
townRoot, _ := workspace.FindFromCwd()
b := beads.New(townRoot)
resolver := mail.NewResolver(b, townRoot)
recipients, err := resolver.Resolve(to)
if err != nil {
// Fall back to legacy routing if resolver fails
router := mail.NewRouter(workDir)
if err := router.Send(msg); err != nil {
return fmt.Errorf("sending message: %w", err)
}
_ = events.LogFeed(events.TypeMail, from, events.MailPayload(to, mailSubject))
fmt.Printf("%s Message sent to %s\n", style.Bold.Render("✓"), to)
fmt.Printf(" Subject: %s\n", mailSubject)
return nil
}
// Route based on recipient type
router := mail.NewRouter(workDir)
var recipientAddrs []string
for _, rec := range recipients {
switch rec.Type {
case mail.RecipientQueue:
// Queue messages: single message, workers claim
msg.To = rec.Address
if err := router.Send(msg); err != nil {
return fmt.Errorf("sending to queue: %w", err)
}
recipientAddrs = append(recipientAddrs, rec.Address)
case mail.RecipientChannel:
// Channel messages: single message, broadcast
msg.To = rec.Address
if err := router.Send(msg); err != nil {
return fmt.Errorf("sending to channel: %w", err)
}
recipientAddrs = append(recipientAddrs, rec.Address)
default:
// Direct/agent messages: fan out to each recipient
msgCopy := *msg
msgCopy.To = rec.Address
if err := router.Send(&msgCopy); err != nil {
return fmt.Errorf("sending to %s: %w", rec.Address, err)
}
recipientAddrs = append(recipientAddrs, rec.Address)
}
}
// Log mail event to activity feed
_ = events.LogFeed(events.TypeMail, from, events.MailPayload(to, mailSubject))
fmt.Printf("%s Message sent to %s\n", style.Bold.Render("✓"), to)
fmt.Printf(" Subject: %s\n", mailSubject)
// Show resolved recipients if fan-out occurred
if len(recipientAddrs) > 1 || (len(recipientAddrs) == 1 && recipientAddrs[0] != to) {
fmt.Printf(" Recipients: %s\n", strings.Join(recipientAddrs, ", "))
}
if len(msg.CC) > 0 {
fmt.Printf(" CC: %s\n", strings.Join(msg.CC, ", "))
}
if msg.Type != mail.TypeNotification {
fmt.Printf(" Type: %s\n", msg.Type)
}
return nil
}
// generateThreadID creates a random thread ID for new message threads.
func generateThreadID() string {
b := make([]byte, 6)
_, _ = rand.Read(b) // crypto/rand.Read only fails on broken system
return "thread-" + hex.EncodeToString(b)
}