The router was missing support for beads-native channel addresses. When mail_send.go resolved an address to RecipientChannel, it set msg.To to "channel:<name>" but router.Send() had no handler for this prefix, causing channel messages to fail silently. Added: - isChannelAddress() and parseChannelName() helper functions - sendToChannel() method that creates messages with proper channel: labels for channel queries - Channel validation before sending - Retention enforcement after message creation Also updated docs/beads-native-messaging.md with more comprehensive documentation of the beads-native messaging system. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
999 lines
30 KiB
Go
999 lines
30 KiB
Go
package mail
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/steveyegge/gastown/internal/beads"
|
|
"github.com/steveyegge/gastown/internal/config"
|
|
"github.com/steveyegge/gastown/internal/session"
|
|
"github.com/steveyegge/gastown/internal/tmux"
|
|
)
|
|
|
|
// ErrUnknownList indicates a mailing list name was not found in configuration.
|
|
var ErrUnknownList = errors.New("unknown mailing list")
|
|
|
|
// ErrUnknownQueue indicates a queue name was not found in configuration.
|
|
var ErrUnknownQueue = errors.New("unknown queue")
|
|
|
|
// ErrUnknownAnnounce indicates an announce channel name was not found in configuration.
|
|
var ErrUnknownAnnounce = errors.New("unknown announce channel")
|
|
|
|
// Router handles message delivery via beads.
|
|
// It routes messages to the correct beads database based on address:
|
|
// - Town-level (mayor/, deacon/) -> {townRoot}/.beads
|
|
// - Rig-level (rig/polecat) -> {townRoot}/{rig}/.beads
|
|
type Router struct {
|
|
workDir string // fallback directory to run bd commands in
|
|
townRoot string // town root directory (e.g., ~/gt)
|
|
tmux *tmux.Tmux
|
|
}
|
|
|
|
// NewRouter creates a new mail router.
|
|
// workDir should be a directory containing a .beads database.
|
|
// The town root is auto-detected from workDir if possible.
|
|
func NewRouter(workDir string) *Router {
|
|
// Try to detect town root from workDir
|
|
townRoot := detectTownRoot(workDir)
|
|
|
|
return &Router{
|
|
workDir: workDir,
|
|
townRoot: townRoot,
|
|
tmux: tmux.NewTmux(),
|
|
}
|
|
}
|
|
|
|
// NewRouterWithTownRoot creates a router with an explicit town root.
|
|
func NewRouterWithTownRoot(workDir, townRoot string) *Router {
|
|
return &Router{
|
|
workDir: workDir,
|
|
townRoot: townRoot,
|
|
tmux: tmux.NewTmux(),
|
|
}
|
|
}
|
|
|
|
// 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:")
|
|
}
|
|
|
|
// isQueueAddress returns true if the address uses queue:name syntax.
|
|
func isQueueAddress(address string) bool {
|
|
return strings.HasPrefix(address, "queue:")
|
|
}
|
|
|
|
// parseQueueName extracts the queue name from a queue:name address.
|
|
func parseQueueName(address string) string {
|
|
return strings.TrimPrefix(address, "queue:")
|
|
}
|
|
|
|
// isAnnounceAddress returns true if the address uses announce:name syntax.
|
|
func isAnnounceAddress(address string) bool {
|
|
return strings.HasPrefix(address, "announce:")
|
|
}
|
|
|
|
// parseAnnounceName extracts the announce channel name from an announce:name address.
|
|
func parseAnnounceName(address string) string {
|
|
return strings.TrimPrefix(address, "announce:")
|
|
}
|
|
|
|
// isChannelAddress returns true if the address uses channel:name syntax (beads-native channels).
|
|
func isChannelAddress(address string) bool {
|
|
return strings.HasPrefix(address, "channel:")
|
|
}
|
|
|
|
// parseChannelName extracts the channel name from a channel:name address.
|
|
func parseChannelName(address string) string {
|
|
return strings.TrimPrefix(address, "channel:")
|
|
}
|
|
|
|
// expandFromConfig is a generic helper for config-based expansion.
|
|
// It loads the messaging config and calls the getter to extract the desired value.
|
|
// This consolidates the common pattern of: check townRoot, load config, lookup in map.
|
|
func expandFromConfig[T any](r *Router, name string, getter func(*config.MessagingConfig) (T, bool), errType error) (T, error) {
|
|
var zero T
|
|
if r.townRoot == "" {
|
|
return zero, fmt.Errorf("%w: %s (no town root)", errType, name)
|
|
}
|
|
|
|
configPath := config.MessagingConfigPath(r.townRoot)
|
|
cfg, err := config.LoadMessagingConfig(configPath)
|
|
if err != nil {
|
|
return zero, fmt.Errorf("loading messaging config: %w", err)
|
|
}
|
|
|
|
result, ok := getter(cfg)
|
|
if !ok {
|
|
return zero, fmt.Errorf("%w: %s", errType, name)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// expandList returns the recipients for a mailing list.
|
|
// Returns ErrUnknownList if the list is not found.
|
|
func (r *Router) expandList(listName string) ([]string, error) {
|
|
recipients, err := expandFromConfig(r, listName, func(cfg *config.MessagingConfig) ([]string, bool) {
|
|
r, ok := cfg.Lists[listName]
|
|
return r, ok
|
|
}, ErrUnknownList)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(recipients) == 0 {
|
|
return nil, fmt.Errorf("%w: %s (empty list)", ErrUnknownList, listName)
|
|
}
|
|
|
|
return recipients, nil
|
|
}
|
|
|
|
// expandQueue returns the QueueConfig for a queue name.
|
|
// Returns ErrUnknownQueue if the queue is not found.
|
|
func (r *Router) expandQueue(queueName string) (*config.QueueConfig, error) {
|
|
return expandFromConfig(r, queueName, func(cfg *config.MessagingConfig) (*config.QueueConfig, bool) {
|
|
qc, ok := cfg.Queues[queueName]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
return &qc, true
|
|
}, ErrUnknownQueue)
|
|
}
|
|
|
|
// expandAnnounce returns the AnnounceConfig for an announce channel name.
|
|
// Returns ErrUnknownAnnounce if the channel is not found.
|
|
func (r *Router) expandAnnounce(announceName string) (*config.AnnounceConfig, error) {
|
|
return expandFromConfig(r, announceName, func(cfg *config.MessagingConfig) (*config.AnnounceConfig, bool) {
|
|
ac, ok := cfg.Announces[announceName]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
return &ac, true
|
|
}, ErrUnknownAnnounce)
|
|
}
|
|
|
|
// detectTownRoot finds the town root by looking for mayor/town.json.
|
|
func detectTownRoot(startDir string) string {
|
|
dir := startDir
|
|
for {
|
|
// Check for primary marker (mayor/town.json)
|
|
markerPath := filepath.Join(dir, "mayor", "town.json")
|
|
if _, err := os.Stat(markerPath); err == nil {
|
|
return dir
|
|
}
|
|
|
|
// Move up
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
break
|
|
}
|
|
dir = parent
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// resolveBeadsDir returns the correct .beads directory for the given address.
|
|
//
|
|
// Two-level beads architecture:
|
|
// - ALL mail uses town beads ({townRoot}/.beads) regardless of address
|
|
// - Rig-level beads ({rig}/.beads) are for project issues only, not mail
|
|
//
|
|
// This ensures messages are visible to all agents in the town.
|
|
func (r *Router) resolveBeadsDir(_ string) string { // address unused: all mail uses town-level beads
|
|
// If no town root, fall back to workDir's .beads
|
|
if r.townRoot == "" {
|
|
return filepath.Join(r.workDir, ".beads")
|
|
}
|
|
|
|
// All mail uses town-level beads
|
|
return filepath.Join(r.townRoot, ".beads")
|
|
}
|
|
|
|
// isTownLevelAddress returns true if the address is for a town-level agent or the overseer.
|
|
func isTownLevelAddress(address string) bool {
|
|
addr := strings.TrimSuffix(address, "/")
|
|
return addr == "mayor" || addr == "deacon" || addr == "overseer"
|
|
}
|
|
|
|
// isGroupAddress returns true if the address is a @group address.
|
|
// Group addresses start with @ and resolve to multiple recipients.
|
|
func isGroupAddress(address string) bool {
|
|
return strings.HasPrefix(address, "@")
|
|
}
|
|
|
|
// GroupType represents the type of group address.
|
|
type GroupType string
|
|
|
|
const (
|
|
GroupTypeRig GroupType = "rig" // @rig/<rigname> - all agents in a rig
|
|
GroupTypeTown GroupType = "town" // @town - all town-level agents
|
|
GroupTypeRole GroupType = "role" // @witnesses, @dogs, etc. - all agents of a role
|
|
GroupTypeRigRole GroupType = "rig-role" // @crew/<rigname>, @polecats/<rigname> - role in a rig
|
|
GroupTypeOverseer GroupType = "overseer" // @overseer - human operator
|
|
)
|
|
|
|
// ParsedGroup represents a parsed @group address.
|
|
type ParsedGroup struct {
|
|
Type GroupType
|
|
RoleType string // witness, crew, polecat, dog, etc.
|
|
Rig string // rig name for rig-scoped groups
|
|
Original string // original @group string
|
|
}
|
|
|
|
// parseGroupAddress parses a @group address into its components.
|
|
// Returns nil if the address is not a valid group address.
|
|
//
|
|
// Supported patterns:
|
|
// - @rig/<rigname>: All agents in a rig
|
|
// - @town: All town-level agents (mayor, deacon)
|
|
// - @witnesses: All witnesses across rigs
|
|
// - @crew/<rigname>: Crew workers in a specific rig
|
|
// - @polecats/<rigname>: Polecats in a specific rig
|
|
// - @dogs: All Deacon dogs
|
|
// - @overseer: Human operator (special case)
|
|
func parseGroupAddress(address string) *ParsedGroup {
|
|
if !isGroupAddress(address) {
|
|
return nil
|
|
}
|
|
|
|
// Remove @ prefix
|
|
group := strings.TrimPrefix(address, "@")
|
|
|
|
// Special cases that don't require parsing
|
|
switch group {
|
|
case "overseer":
|
|
return &ParsedGroup{Type: GroupTypeOverseer, Original: address}
|
|
case "town":
|
|
return &ParsedGroup{Type: GroupTypeTown, Original: address}
|
|
case "witnesses":
|
|
return &ParsedGroup{Type: GroupTypeRole, RoleType: "witness", Original: address}
|
|
case "dogs":
|
|
return &ParsedGroup{Type: GroupTypeRole, RoleType: "dog", Original: address}
|
|
case "refineries":
|
|
return &ParsedGroup{Type: GroupTypeRole, RoleType: "refinery", Original: address}
|
|
case "deacons":
|
|
return &ParsedGroup{Type: GroupTypeRole, RoleType: "deacon", Original: address}
|
|
}
|
|
|
|
// Parse patterns with slashes: @rig/<name>, @crew/<rig>, @polecats/<rig>
|
|
parts := strings.SplitN(group, "/", 2)
|
|
if len(parts) != 2 || parts[1] == "" {
|
|
return nil // Invalid format
|
|
}
|
|
|
|
prefix, qualifier := parts[0], parts[1]
|
|
|
|
switch prefix {
|
|
case "rig":
|
|
return &ParsedGroup{Type: GroupTypeRig, Rig: qualifier, Original: address}
|
|
case "crew":
|
|
return &ParsedGroup{Type: GroupTypeRigRole, RoleType: "crew", Rig: qualifier, Original: address}
|
|
case "polecats":
|
|
return &ParsedGroup{Type: GroupTypeRigRole, RoleType: "polecat", Rig: qualifier, Original: address}
|
|
default:
|
|
return nil // Unknown group type
|
|
}
|
|
}
|
|
|
|
// agentBead represents an agent bead as returned by bd list --type=agent.
|
|
type agentBead struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
// agentBeadToAddress converts an agent bead to a mail address.
|
|
// Uses the agent bead ID to derive the address:
|
|
// - gt-mayor → mayor/
|
|
// - gt-deacon → deacon/
|
|
// - gt-gastown-witness → gastown/witness
|
|
// - gt-gastown-crew-max → gastown/max
|
|
// - gt-gastown-polecat-Toast → gastown/Toast
|
|
func agentBeadToAddress(bead *agentBead) string {
|
|
if bead == nil {
|
|
return ""
|
|
}
|
|
|
|
id := bead.ID
|
|
if !strings.HasPrefix(id, "gt-") {
|
|
return "" // Not a valid agent bead ID
|
|
}
|
|
|
|
// Strip prefix
|
|
rest := strings.TrimPrefix(id, "gt-")
|
|
parts := strings.Split(rest, "-")
|
|
|
|
switch len(parts) {
|
|
case 1:
|
|
// Town-level: gt-mayor, gt-deacon
|
|
return parts[0] + "/"
|
|
case 2:
|
|
// Rig singleton: gt-gastown-witness
|
|
return parts[0] + "/" + parts[1]
|
|
default:
|
|
// Rig named agent: gt-gastown-crew-max, gt-gastown-polecat-Toast
|
|
// Skip the role part (parts[1]) and use rig/name format
|
|
if len(parts) >= 3 {
|
|
// Rejoin if name has hyphens: gt-gastown-polecat-my-agent
|
|
name := strings.Join(parts[2:], "-")
|
|
return parts[0] + "/" + name
|
|
}
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// ResolveGroupAddress resolves a @group address to individual recipient addresses.
|
|
// Returns the list of resolved addresses and any error.
|
|
// This is the public entry point for group resolution.
|
|
func (r *Router) ResolveGroupAddress(address string) ([]string, error) {
|
|
group := parseGroupAddress(address)
|
|
if group == nil {
|
|
return nil, fmt.Errorf("invalid group address: %s", address)
|
|
}
|
|
return r.resolveGroup(group)
|
|
}
|
|
|
|
// resolveGroup resolves a @group address to individual recipient addresses.
|
|
// Returns the list of resolved addresses and any error.
|
|
func (r *Router) resolveGroup(group *ParsedGroup) ([]string, error) {
|
|
if group == nil {
|
|
return nil, errors.New("nil group")
|
|
}
|
|
|
|
switch group.Type {
|
|
case GroupTypeOverseer:
|
|
return r.resolveOverseer()
|
|
case GroupTypeTown:
|
|
return r.resolveTownAgents()
|
|
case GroupTypeRole:
|
|
return r.resolveAgentsByRole(group.RoleType, "")
|
|
case GroupTypeRig:
|
|
return r.resolveAgentsByRig(group.Rig)
|
|
case GroupTypeRigRole:
|
|
return r.resolveAgentsByRole(group.RoleType, group.Rig)
|
|
default:
|
|
return nil, fmt.Errorf("unknown group type: %s", group.Type)
|
|
}
|
|
}
|
|
|
|
// resolveOverseer resolves @overseer to the human operator's address.
|
|
// Loads the overseer config and returns "overseer" as the address.
|
|
func (r *Router) resolveOverseer() ([]string, error) {
|
|
if r.townRoot == "" {
|
|
return nil, errors.New("town root not set, cannot resolve @overseer")
|
|
}
|
|
|
|
// Load overseer config to verify it exists
|
|
configPath := config.OverseerConfigPath(r.townRoot)
|
|
_, err := config.LoadOverseerConfig(configPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolving @overseer: %w", err)
|
|
}
|
|
|
|
// Return the overseer address
|
|
return []string{"overseer"}, nil
|
|
}
|
|
|
|
// resolveTownAgents resolves @town to all town-level agents (mayor, deacon).
|
|
func (r *Router) resolveTownAgents() ([]string, error) {
|
|
// Town-level agents have rig=null in their description
|
|
agents, err := r.queryAgents("rig: null")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var addresses []string
|
|
for _, agent := range agents {
|
|
if addr := agentBeadToAddress(agent); addr != "" {
|
|
addresses = append(addresses, addr)
|
|
}
|
|
}
|
|
|
|
return addresses, nil
|
|
}
|
|
|
|
// resolveAgentsByRole resolves agents by their role_type.
|
|
// If rig is non-empty, also filters by rig.
|
|
func (r *Router) resolveAgentsByRole(roleType, rig string) ([]string, error) {
|
|
// Build query filter
|
|
query := "role_type: " + roleType
|
|
agents, err := r.queryAgents(query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var addresses []string
|
|
for _, agent := range agents {
|
|
// Filter by rig if specified
|
|
if rig != "" {
|
|
// Check if agent's description contains matching rig
|
|
if !strings.Contains(agent.Description, "rig: "+rig) {
|
|
continue
|
|
}
|
|
}
|
|
if addr := agentBeadToAddress(agent); addr != "" {
|
|
addresses = append(addresses, addr)
|
|
}
|
|
}
|
|
|
|
return addresses, nil
|
|
}
|
|
|
|
// resolveAgentsByRig resolves @rig/<rigname> to all agents in that rig.
|
|
func (r *Router) resolveAgentsByRig(rig string) ([]string, error) {
|
|
// Query for agents with matching rig in description
|
|
query := "rig: " + rig
|
|
agents, err := r.queryAgents(query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var addresses []string
|
|
for _, agent := range agents {
|
|
if addr := agentBeadToAddress(agent); addr != "" {
|
|
addresses = append(addresses, addr)
|
|
}
|
|
}
|
|
|
|
return addresses, nil
|
|
}
|
|
|
|
// queryAgents queries agent beads using bd list with description filtering.
|
|
func (r *Router) queryAgents(descContains string) ([]*agentBead, error) {
|
|
beadsDir := r.resolveBeadsDir("")
|
|
args := []string{"list", "--type=agent", "--json", "--limit=0"}
|
|
|
|
if descContains != "" {
|
|
args = append(args, "--desc-contains="+descContains)
|
|
}
|
|
|
|
stdout, err := runBdCommand(args, filepath.Dir(beadsDir), beadsDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying agents: %w", err)
|
|
}
|
|
|
|
var agents []*agentBead
|
|
if err := json.Unmarshal(stdout, &agents); err != nil {
|
|
return nil, fmt.Errorf("parsing agent query result: %w", err)
|
|
}
|
|
|
|
// Filter for open agents only (closed agents are inactive)
|
|
var active []*agentBead
|
|
for _, agent := range agents {
|
|
if agent.Status == "open" || agent.Status == "in_progress" {
|
|
active = append(active, agent)
|
|
}
|
|
}
|
|
|
|
return active, nil
|
|
}
|
|
|
|
// shouldBeWisp determines if a message should be stored as a wisp.
|
|
// Returns true if:
|
|
// - Message.Wisp is explicitly set
|
|
// - Subject matches lifecycle message patterns (POLECAT_*, NUDGE, etc.)
|
|
func (r *Router) shouldBeWisp(msg *Message) bool {
|
|
if msg.Wisp {
|
|
return true
|
|
}
|
|
// Auto-detect lifecycle messages by subject prefix
|
|
subjectLower := strings.ToLower(msg.Subject)
|
|
wispPrefixes := []string{
|
|
"polecat_started",
|
|
"polecat_done",
|
|
"start_work",
|
|
"nudge",
|
|
}
|
|
for _, prefix := range wispPrefixes {
|
|
if strings.HasPrefix(subjectLower, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Send delivers a message via beads message.
|
|
// Routes the message to the correct beads database based on recipient address.
|
|
// Supports fan-out for:
|
|
// - Mailing lists (list:name) - fans out to all list members
|
|
// - @group addresses - resolves and fans out to matching agents
|
|
// Supports single-copy delivery for:
|
|
// - Queues (queue:name) - stores single message for worker claiming
|
|
// - Announces (announce:name) - bulletin board, no claiming, retention-limited
|
|
func (r *Router) Send(msg *Message) error {
|
|
// Check for mailing list address
|
|
if isListAddress(msg.To) {
|
|
return r.sendToList(msg)
|
|
}
|
|
|
|
// Check for queue address - single message for claiming
|
|
if isQueueAddress(msg.To) {
|
|
return r.sendToQueue(msg)
|
|
}
|
|
|
|
// Check for announce address - bulletin board (single copy, no claiming)
|
|
if isAnnounceAddress(msg.To) {
|
|
return r.sendToAnnounce(msg)
|
|
}
|
|
|
|
// Check for beads-native channel address - broadcast with retention
|
|
if isChannelAddress(msg.To) {
|
|
return r.sendToChannel(msg)
|
|
}
|
|
|
|
// Check for @group address - resolve and fan-out
|
|
if isGroupAddress(msg.To) {
|
|
return r.sendToGroup(msg)
|
|
}
|
|
|
|
// Single recipient - send directly
|
|
return r.sendToSingle(msg)
|
|
}
|
|
|
|
// sendToGroup resolves a @group address and sends individual messages to each member.
|
|
func (r *Router) sendToGroup(msg *Message) error {
|
|
group := parseGroupAddress(msg.To)
|
|
if group == nil {
|
|
return fmt.Errorf("invalid group address: %s", msg.To)
|
|
}
|
|
|
|
recipients, err := r.resolveGroup(group)
|
|
if err != nil {
|
|
return fmt.Errorf("resolving group %s: %w", msg.To, err)
|
|
}
|
|
|
|
if len(recipients) == 0 {
|
|
return fmt.Errorf("no recipients found for group: %s", msg.To)
|
|
}
|
|
|
|
// Fan-out: send a copy to each recipient
|
|
var errs []string
|
|
for _, recipient := range recipients {
|
|
// Create a copy of the message for this recipient
|
|
msgCopy := *msg
|
|
msgCopy.To = recipient
|
|
|
|
if err := r.sendToSingle(&msgCopy); err != nil {
|
|
errs = append(errs, fmt.Sprintf("%s: %v", recipient, err))
|
|
}
|
|
}
|
|
|
|
if len(errs) > 0 {
|
|
return fmt.Errorf("some group sends failed: %s", strings.Join(errs, "; "))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// sendToSingle sends a message to a single recipient.
|
|
func (r *Router) sendToSingle(msg *Message) error {
|
|
// Convert addresses to beads identities
|
|
toIdentity := addressToIdentity(msg.To)
|
|
|
|
// Build labels for from/thread/reply-to/cc
|
|
var labels []string
|
|
labels = append(labels, "from:"+msg.From)
|
|
if msg.ThreadID != "" {
|
|
labels = append(labels, "thread:"+msg.ThreadID)
|
|
}
|
|
if msg.ReplyTo != "" {
|
|
labels = append(labels, "reply-to:"+msg.ReplyTo)
|
|
}
|
|
// Add CC labels (one per recipient)
|
|
for _, cc := range msg.CC {
|
|
ccIdentity := addressToIdentity(cc)
|
|
labels = append(labels, "cc:"+ccIdentity)
|
|
}
|
|
|
|
// Build command: bd create <subject> --type=message --assignee=<recipient> -d <body>
|
|
args := []string{"create", msg.Subject,
|
|
"--type", "message",
|
|
"--assignee", toIdentity,
|
|
"-d", msg.Body,
|
|
}
|
|
|
|
// Add priority flag
|
|
beadsPriority := PriorityToBeads(msg.Priority)
|
|
args = append(args, "--priority", fmt.Sprintf("%d", beadsPriority))
|
|
|
|
// Add labels
|
|
if len(labels) > 0 {
|
|
args = append(args, "--labels", strings.Join(labels, ","))
|
|
}
|
|
|
|
// Add actor for attribution (sender identity)
|
|
args = append(args, "--actor", msg.From)
|
|
|
|
// Add --ephemeral flag for ephemeral messages (stored in single DB, filtered from JSONL export)
|
|
if r.shouldBeWisp(msg) {
|
|
args = append(args, "--ephemeral")
|
|
}
|
|
|
|
beadsDir := r.resolveBeadsDir(msg.To)
|
|
_, err := runBdCommand(args, filepath.Dir(beadsDir), beadsDir)
|
|
if err != nil {
|
|
return fmt.Errorf("sending message: %w", err)
|
|
}
|
|
|
|
// Notify recipient if they have an active session (best-effort notification)
|
|
// Skip notification for self-mail (handoffs to future-self don't need present-self notified)
|
|
if !isSelfMail(msg.From, msg.To) {
|
|
_ = r.notifyRecipient(msg)
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
// sendToQueue delivers a message to a queue for worker claiming.
|
|
// Unlike sendToList, this creates a SINGLE message (no fan-out).
|
|
// The message is stored in town-level beads with queue metadata.
|
|
// Workers claim messages using bd update --claimed-by.
|
|
func (r *Router) sendToQueue(msg *Message) error {
|
|
queueName := parseQueueName(msg.To)
|
|
|
|
// Validate queue exists in messaging config
|
|
_, err := r.expandQueue(queueName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Build labels for from/thread/reply-to/cc plus queue metadata
|
|
var labels []string
|
|
labels = append(labels, "from:"+msg.From)
|
|
labels = append(labels, "queue:"+queueName)
|
|
if msg.ThreadID != "" {
|
|
labels = append(labels, "thread:"+msg.ThreadID)
|
|
}
|
|
if msg.ReplyTo != "" {
|
|
labels = append(labels, "reply-to:"+msg.ReplyTo)
|
|
}
|
|
for _, cc := range msg.CC {
|
|
ccIdentity := addressToIdentity(cc)
|
|
labels = append(labels, "cc:"+ccIdentity)
|
|
}
|
|
|
|
// Build command: bd create <subject> --type=message --assignee=queue:<name> -d <body>
|
|
// Use queue:<name> as assignee so inbox queries can filter by queue
|
|
args := []string{"create", msg.Subject,
|
|
"--type", "message",
|
|
"--assignee", msg.To, // queue:name
|
|
"-d", msg.Body,
|
|
}
|
|
|
|
// Add priority flag
|
|
beadsPriority := PriorityToBeads(msg.Priority)
|
|
args = append(args, "--priority", fmt.Sprintf("%d", beadsPriority))
|
|
|
|
// Add labels (includes queue name for filtering)
|
|
if len(labels) > 0 {
|
|
args = append(args, "--labels", strings.Join(labels, ","))
|
|
}
|
|
|
|
// Add actor for attribution (sender identity)
|
|
args = append(args, "--actor", msg.From)
|
|
|
|
// Queue messages are never ephemeral - they need to persist until claimed
|
|
// (deliberately not checking shouldBeWisp)
|
|
|
|
// Queue messages go to town-level beads (shared location)
|
|
beadsDir := r.resolveBeadsDir("")
|
|
_, err = runBdCommand(args, filepath.Dir(beadsDir), beadsDir)
|
|
if err != nil {
|
|
return fmt.Errorf("sending to queue %s: %w", queueName, err)
|
|
}
|
|
|
|
// No notification for queue messages - workers poll or check on their own schedule
|
|
|
|
return nil
|
|
}
|
|
|
|
// sendToAnnounce delivers a message to an announce channel (bulletin board).
|
|
// Unlike sendToQueue, no claiming is supported - messages persist until retention limit.
|
|
// ONE copy is stored in town-level beads with announce_channel metadata.
|
|
func (r *Router) sendToAnnounce(msg *Message) error {
|
|
announceName := parseAnnounceName(msg.To)
|
|
|
|
// Validate announce channel exists and get config
|
|
announceCfg, err := r.expandAnnounce(announceName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Apply retention pruning BEFORE creating new message
|
|
if announceCfg.RetainCount > 0 {
|
|
if err := r.pruneAnnounce(announceName, announceCfg.RetainCount); err != nil {
|
|
// Log but don't fail - pruning is best-effort
|
|
// The new message should still be created
|
|
_ = err
|
|
}
|
|
}
|
|
|
|
// Build labels for from/thread/reply-to/cc plus announce metadata
|
|
var labels []string
|
|
labels = append(labels, "from:"+msg.From)
|
|
labels = append(labels, "announce:"+announceName)
|
|
if msg.ThreadID != "" {
|
|
labels = append(labels, "thread:"+msg.ThreadID)
|
|
}
|
|
if msg.ReplyTo != "" {
|
|
labels = append(labels, "reply-to:"+msg.ReplyTo)
|
|
}
|
|
for _, cc := range msg.CC {
|
|
ccIdentity := addressToIdentity(cc)
|
|
labels = append(labels, "cc:"+ccIdentity)
|
|
}
|
|
|
|
// Build command: bd create <subject> --type=message --assignee=announce:<name> -d <body>
|
|
// Use announce:<name> as assignee so queries can filter by channel
|
|
args := []string{"create", msg.Subject,
|
|
"--type", "message",
|
|
"--assignee", msg.To, // announce:name
|
|
"-d", msg.Body,
|
|
}
|
|
|
|
// Add priority flag
|
|
beadsPriority := PriorityToBeads(msg.Priority)
|
|
args = append(args, "--priority", fmt.Sprintf("%d", beadsPriority))
|
|
|
|
// Add labels (includes announce name for filtering)
|
|
if len(labels) > 0 {
|
|
args = append(args, "--labels", strings.Join(labels, ","))
|
|
}
|
|
|
|
// Add actor for attribution (sender identity)
|
|
args = append(args, "--actor", msg.From)
|
|
|
|
// Announce messages are never ephemeral - they need to persist for readers
|
|
// (deliberately not checking shouldBeWisp)
|
|
|
|
// Announce messages go to town-level beads (shared location)
|
|
beadsDir := r.resolveBeadsDir("")
|
|
_, err = runBdCommand(args, filepath.Dir(beadsDir), beadsDir)
|
|
if err != nil {
|
|
return fmt.Errorf("sending to announce %s: %w", announceName, err)
|
|
}
|
|
|
|
// No notification for announce messages - readers poll or check on their own schedule
|
|
|
|
return nil
|
|
}
|
|
|
|
// sendToChannel delivers a message to a beads-native channel.
|
|
// Creates a message with channel:<name> label for channel queries.
|
|
// Retention is enforced by the channel's EnforceChannelRetention after message creation.
|
|
func (r *Router) sendToChannel(msg *Message) error {
|
|
channelName := parseChannelName(msg.To)
|
|
|
|
// Validate channel exists as a beads-native channel
|
|
if r.townRoot == "" {
|
|
return fmt.Errorf("town root not set, cannot send to channel: %s", channelName)
|
|
}
|
|
b := beads.New(r.townRoot)
|
|
_, fields, err := b.GetChannelBead(channelName)
|
|
if err != nil {
|
|
return fmt.Errorf("getting channel %s: %w", channelName, err)
|
|
}
|
|
if fields == nil {
|
|
return fmt.Errorf("channel not found: %s", channelName)
|
|
}
|
|
|
|
// Build labels for from/thread/reply-to/cc plus channel metadata
|
|
var labels []string
|
|
labels = append(labels, "from:"+msg.From)
|
|
labels = append(labels, "channel:"+channelName)
|
|
if msg.ThreadID != "" {
|
|
labels = append(labels, "thread:"+msg.ThreadID)
|
|
}
|
|
if msg.ReplyTo != "" {
|
|
labels = append(labels, "reply-to:"+msg.ReplyTo)
|
|
}
|
|
for _, cc := range msg.CC {
|
|
ccIdentity := addressToIdentity(cc)
|
|
labels = append(labels, "cc:"+ccIdentity)
|
|
}
|
|
|
|
// Build command: bd create <subject> --type=message --assignee=channel:<name> -d <body>
|
|
// Use channel:<name> as assignee so queries can filter by channel
|
|
args := []string{"create", msg.Subject,
|
|
"--type", "message",
|
|
"--assignee", msg.To, // channel:name
|
|
"-d", msg.Body,
|
|
}
|
|
|
|
// Add priority flag
|
|
beadsPriority := PriorityToBeads(msg.Priority)
|
|
args = append(args, "--priority", fmt.Sprintf("%d", beadsPriority))
|
|
|
|
// Add labels (includes channel name for filtering)
|
|
if len(labels) > 0 {
|
|
args = append(args, "--labels", strings.Join(labels, ","))
|
|
}
|
|
|
|
// Add actor for attribution (sender identity)
|
|
args = append(args, "--actor", msg.From)
|
|
|
|
// Channel messages are never ephemeral - they persist according to retention policy
|
|
// (deliberately not checking shouldBeWisp)
|
|
|
|
// Channel messages go to town-level beads (shared location)
|
|
beadsDir := r.resolveBeadsDir("")
|
|
_, err = runBdCommand(args, filepath.Dir(beadsDir), beadsDir)
|
|
if err != nil {
|
|
return fmt.Errorf("sending to channel %s: %w", channelName, err)
|
|
}
|
|
|
|
// Enforce channel retention policy (on-write cleanup)
|
|
_ = b.EnforceChannelRetention(channelName)
|
|
|
|
// No notification for channel messages - readers poll or check on their own schedule
|
|
|
|
return nil
|
|
}
|
|
|
|
// pruneAnnounce deletes oldest messages from an announce channel to enforce retention.
|
|
// If the channel has >= retainCount messages, deletes the oldest until count < retainCount.
|
|
func (r *Router) pruneAnnounce(announceName string, retainCount int) error {
|
|
if retainCount <= 0 {
|
|
return nil // No retention limit
|
|
}
|
|
|
|
beadsDir := r.resolveBeadsDir("")
|
|
|
|
// Query existing messages in this announce channel
|
|
// Use bd list with labels filter to find messages with announce:<name> label
|
|
args := []string{"list",
|
|
"--type=message",
|
|
"--labels=announce:" + announceName,
|
|
"--json",
|
|
"--limit=0", // Get all
|
|
"--sort=created",
|
|
"--asc", // Oldest first
|
|
}
|
|
|
|
stdout, err := runBdCommand(args, filepath.Dir(beadsDir), beadsDir)
|
|
if err != nil {
|
|
return fmt.Errorf("querying announce messages: %w", err)
|
|
}
|
|
|
|
// Parse message list
|
|
var messages []struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if err := json.Unmarshal(stdout, &messages); err != nil {
|
|
return fmt.Errorf("parsing announce messages: %w", err)
|
|
}
|
|
|
|
// Calculate how many to delete (we're about to add 1 more)
|
|
// If we have N messages and retainCount is R, we need to keep at most R-1 after pruning
|
|
// so the new message makes it exactly R
|
|
toDelete := len(messages) - (retainCount - 1)
|
|
if toDelete <= 0 {
|
|
return nil // No pruning needed
|
|
}
|
|
|
|
// Delete oldest messages
|
|
for i := 0; i < toDelete && i < len(messages); i++ {
|
|
deleteArgs := []string{"close", messages[i].ID, "--reason=retention pruning"}
|
|
// Best-effort deletion - don't fail if one delete fails
|
|
_, _ = runBdCommand(deleteArgs, filepath.Dir(beadsDir), beadsDir)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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 {
|
|
fromNorm := strings.TrimSuffix(from, "/")
|
|
toNorm := strings.TrimSuffix(to, "/")
|
|
return fromNorm == toNorm
|
|
}
|
|
|
|
// GetMailbox returns a Mailbox for the given address.
|
|
// Routes to the correct beads database based on the address.
|
|
func (r *Router) GetMailbox(address string) (*Mailbox, error) {
|
|
beadsDir := r.resolveBeadsDir(address)
|
|
workDir := filepath.Dir(beadsDir) // Parent of .beads
|
|
return NewMailboxFromAddress(address, workDir), nil
|
|
}
|
|
|
|
// notifyRecipient sends a notification to a recipient's tmux session.
|
|
// Uses NudgeSession to add the notification to the agent's conversation history.
|
|
// Supports mayor/, rig/polecat, and rig/refinery addresses.
|
|
func (r *Router) notifyRecipient(msg *Message) error {
|
|
sessionID := addressToSessionID(msg.To)
|
|
if sessionID == "" {
|
|
return nil // Unable to determine session ID
|
|
}
|
|
|
|
// Check if session exists
|
|
hasSession, err := r.tmux.HasSession(sessionID)
|
|
if err != nil || !hasSession {
|
|
return nil // No active session, skip notification
|
|
}
|
|
|
|
// Send notification to the agent's conversation history
|
|
notification := fmt.Sprintf("📬 You have new mail from %s. Subject: %s. Run 'gt mail inbox' to read.", msg.From, msg.Subject)
|
|
return r.tmux.NudgeSession(sessionID, notification)
|
|
}
|
|
|
|
// addressToSessionID converts a mail address to a tmux session ID.
|
|
// Returns empty string if address format is not recognized.
|
|
func addressToSessionID(address string) string {
|
|
// Mayor address: "mayor/" or "mayor"
|
|
if strings.HasPrefix(address, "mayor") {
|
|
return session.MayorSessionName()
|
|
}
|
|
|
|
// Deacon address: "deacon/" or "deacon"
|
|
if strings.HasPrefix(address, "deacon") {
|
|
return session.DeaconSessionName()
|
|
}
|
|
|
|
// Rig-based address: "rig/target"
|
|
parts := strings.SplitN(address, "/", 2)
|
|
if len(parts) != 2 || parts[1] == "" {
|
|
return ""
|
|
}
|
|
|
|
rig := parts[0]
|
|
target := parts[1]
|
|
|
|
// Polecat: gt-rig-polecat
|
|
// Refinery: gt-rig-refinery (if refinery has its own session)
|
|
return fmt.Sprintf("gt-%s-%s", rig, target)
|
|
}
|