package mail import ( "bytes" "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/workspace" ) // 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:") } // 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 } // 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) { // Load messaging config from town root if r.townRoot == "" { return nil, fmt.Errorf("%w: %s (no town root)", ErrUnknownQueue, queueName) } configPath := config.MessagingConfigPath(r.townRoot) cfg, err := config.LoadMessagingConfig(configPath) if err != nil { return nil, fmt.Errorf("loading messaging config: %w", err) } queueCfg, ok := cfg.Queues[queueName] if !ok { return nil, fmt.Errorf("%w: %s", ErrUnknownQueue, queueName) } return &queueCfg, nil } // 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) { // Load messaging config from town root if r.townRoot == "" { return nil, fmt.Errorf("%w: %s (no town root)", ErrUnknownAnnounce, announceName) } configPath := config.MessagingConfigPath(r.townRoot) cfg, err := config.LoadMessagingConfig(configPath) if err != nil { return nil, fmt.Errorf("loading messaging config: %w", err) } announceCfg, ok := cfg.Announces[announceName] if !ok { return nil, fmt.Errorf("%w: %s", ErrUnknownAnnounce, announceName) } return &announceCfg, nil } // 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(address string) string { // 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/ - 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/, @polecats/ - 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/: All agents in a rig // - @town: All town-level agents (mayor, deacon) // - @witnesses: All witnesses across rigs // - @crew/: Crew workers in a specific rig // - @polecats/: 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/, @crew/, @polecats/ 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/ 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) } cmd := exec.Command("bd", args...) cmd.Env = append(cmd.Environ(), "BEADS_DIR="+beadsDir) cmd.Dir = filepath.Dir(beadsDir) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { errMsg := strings.TrimSpace(stderr.String()) if errMsg != "" { return nil, errors.New(errMsg) } return nil, fmt.Errorf("querying agents: %w", err) } var agents []*agentBead if err := json.Unmarshal(stdout.Bytes(), &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 @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 --type=message --assignee= -d 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) cmd := exec.Command("bd", args...) cmd.Env = append(cmd.Environ(), "BEADS_DIR="+beadsDir, ) cmd.Dir = filepath.Dir(beadsDir) // Run in parent of .beads var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { errMsg := strings.TrimSpace(stderr.String()) if errMsg != "" { return errors.New(errMsg) } 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 --type=message --assignee=queue: -d // Use queue: 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("") cmd := exec.Command("bd", args...) cmd.Env = append(cmd.Environ(), "BEADS_DIR="+beadsDir, ) cmd.Dir = filepath.Dir(beadsDir) // Run in parent of .beads var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { errMsg := strings.TrimSpace(stderr.String()) if errMsg != "" { return errors.New(errMsg) } 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 --type=message --assignee=announce: -d // Use announce: 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("") cmd := exec.Command("bd", args...) cmd.Env = append(cmd.Environ(), "BEADS_DIR="+beadsDir, ) cmd.Dir = filepath.Dir(beadsDir) // Run in parent of .beads var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { errMsg := strings.TrimSpace(stderr.String()) if errMsg != "" { return errors.New(errMsg) } 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 } // 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: label args := []string{"list", "--type=message", "--labels=announce:" + announceName, "--json", "--limit=0", // Get all "--sort=created", "--asc", // Oldest first } cmd := exec.Command("bd", args...) cmd.Env = append(cmd.Environ(), "BEADS_DIR="+beadsDir) cmd.Dir = filepath.Dir(beadsDir) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { errMsg := strings.TrimSpace(stderr.String()) if errMsg != "" { return errors.New(errMsg) } return fmt.Errorf("querying announce messages: %w", err) } // Parse message list var messages []struct { ID string `json:"id"` } if err := json.Unmarshal(stdout.Bytes(), &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"} deleteCmd := exec.Command("bd", deleteArgs...) deleteCmd.Env = append(deleteCmd.Environ(), "BEADS_DIR="+beadsDir) deleteCmd.Dir = filepath.Dir(beadsDir) // Best-effort deletion - don't fail if one delete fails _ = deleteCmd.Run() } 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 send-keys to echo a visible banner to ensure notification is seen. // Supports mayor/, rig/polecat, and rig/refinery addresses. func (r *Router) notifyRecipient(msg *Message) error { // Get town name for session name generation var townName string if r.townRoot != "" { townName, _ = workspace.GetTownName(r.townRoot) } sessionID := addressToSessionID(msg.To, townName) 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 visible notification banner to the terminal return r.tmux.SendNotificationBanner(sessionID, msg.From, msg.Subject) } // addressToSessionID converts a mail address to a tmux session ID. // Returns empty string if address format is not recognized. func addressToSessionID(address, townName string) string { // Mayor address: "mayor/" or "mayor" if strings.HasPrefix(address, "mayor") { if townName != "" { return session.MayorSessionName(townName) } return "" // Cannot generate session name without town name } // Deacon address: "deacon/" or "deacon" if strings.HasPrefix(address, "deacon") { if townName != "" { return session.DeaconSessionName(townName) } return "" // Cannot generate session name without town name } // 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) }