package cmd import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "os" "path/filepath" "strings" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/events" "github.com/steveyegge/gastown/internal/mail" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/workspace" ) // Mail command flags var ( mailSubject string mailBody string mailPriority int mailUrgent bool mailPinned bool mailWisp bool mailPermanent bool mailType string mailReplyTo string mailNotify bool mailSendSelf bool mailCC []string // CC recipients mailInboxJSON bool mailReadJSON bool mailInboxUnread bool mailInboxIdentity string mailCheckInject bool mailCheckJSON bool mailCheckIdentity string mailThreadJSON bool mailReplySubject string mailReplyMessage string ) var mailCmd = &cobra.Command{ Use: "mail", GroupID: GroupComm, Short: "Agent messaging system", RunE: requireSubcommand, Long: `Send and receive messages between agents. The mail system allows Mayor, polecats, and the Refinery to communicate. Messages are stored in beads as issues with type=message. MAIL ROUTING: ┌─────────────────────────────────────────────────────┐ │ Town (.beads/) │ │ ┌─────────────────────────────────────────────┐ │ │ │ Mayor Inbox │ │ │ │ └── mayor/ │ │ │ └─────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────┐ │ │ │ gastown/ (rig mailboxes) │ │ │ │ ├── witness ← greenplace/witness │ │ │ │ ├── refinery ← greenplace/refinery │ │ │ │ ├── Toast ← greenplace/Toast │ │ │ │ └── crew/max ← greenplace/crew/max │ │ │ └─────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────┘ ADDRESS FORMATS: mayor/ → Mayor inbox /witness → Rig's Witness /refinery → Rig's Refinery / → Polecat (e.g., greenplace/Toast) /crew/ → Crew worker (e.g., greenplace/crew/max) --human → Special: human overseer COMMANDS: inbox View your inbox send Send a message read Read a specific message mark Mark messages read/unread`, } var mailSendCmd = &cobra.Command{ Use: "send
", Short: "Send a message", Long: `Send a message to an agent. Addresses: mayor/ - Send to Mayor /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 scavenge - Optional first-come work notification - Informational (default) reply - Response to message Priority levels: 0 - urgent/critical 1 - high 2 - normal (default) 3 - low 4 - backlog Use --urgent as shortcut for --priority 0. Examples: gt mail send greenplace/Toast -s "Status check" -m "How's that bug fix going?" gt mail send mayor/ -s "Work complete" -m "Finished gt-abc" gt mail send gastown/ -s "All hands" -m "Swarm starting" --notify gt mail send greenplace/Toast -s "Task" -m "Fix bug" --type task --priority 1 gt mail send greenplace/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 greenplace/Toast -s "Update" -m "Progress report" --cc overseer gt mail send list:oncall -s "Alert" -m "System down"`, Args: cobra.MaximumNArgs(1), RunE: runMailSend, } var mailInboxCmd = &cobra.Command{ Use: "inbox [address]", Short: "Check inbox", Long: `Check messages in an inbox. If no address is specified, shows the current context's inbox. Use --identity for polecats to explicitly specify their identity. Examples: gt mail inbox # Current context (auto-detected) gt mail inbox mayor/ # Mayor's inbox gt mail inbox greenplace/Toast # Polecat's inbox gt mail inbox --identity greenplace/Toast # Explicit polecat identity`, Args: cobra.MaximumNArgs(1), RunE: runMailInbox, } var mailReadCmd = &cobra.Command{ Use: "read ", Short: "Read a message", Long: `Read a specific message and mark it as read. The message ID can be found from 'gt mail inbox'.`, Args: cobra.ExactArgs(1), RunE: runMailRead, } var mailPeekCmd = &cobra.Command{ Use: "peek", Short: "Show preview of first unread message", Long: `Display a compact preview of the first unread message. Useful for status bar popups - shows subject, sender, and body preview. Exits silently with code 1 if no unread messages.`, RunE: runMailPeek, } var mailDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete a message", Long: `Delete (acknowledge) a message. This closes the message in beads.`, Args: cobra.ExactArgs(1), RunE: runMailDelete, } var mailArchiveCmd = &cobra.Command{ Use: "archive ", Short: "Archive a message", Long: `Archive a message (alias for delete). Removes the message from your inbox by closing it in beads.`, Args: cobra.ExactArgs(1), RunE: runMailArchive, } var mailCheckCmd = &cobra.Command{ Use: "check", Short: "Check for new mail (for hooks)", Long: `Check for new mail - useful for Claude Code hooks. Exit codes (normal mode): 0 - New mail available 1 - No new mail Exit codes (--inject mode): 0 - Always (hooks should never block) Output: system-reminder if mail exists, silent if no mail Use --identity for polecats to explicitly specify their identity. Examples: gt mail check # Simple check (auto-detect identity) gt mail check --inject # For hooks gt mail check --identity greenplace/Toast # Explicit polecat identity`, RunE: runMailCheck, } var mailThreadCmd = &cobra.Command{ Use: "thread ", Short: "View a message thread", Long: `View all messages in a conversation thread. Shows messages in chronological order (oldest first). Examples: gt mail thread thread-abc123`, Args: cobra.ExactArgs(1), RunE: runMailThread, } var mailReplyCmd = &cobra.Command{ Use: "reply ", Short: "Reply to a message", Long: `Reply to a specific message. This is a convenience command that automatically: - Sets the reply-to field to the original message - Prefixes the subject with "Re: " (if not already present) - Sends to the original sender Examples: gt mail reply msg-abc123 -m "Thanks, working on it now" gt mail reply msg-abc123 -s "Custom subject" -m "Reply body"`, Args: cobra.ExactArgs(1), RunE: runMailReply, } func init() { // Send flags mailSendCmd.Flags().StringVarP(&mailSubject, "subject", "s", "", "Message subject (required)") mailSendCmd.Flags().StringVarP(&mailBody, "message", "m", "", "Message body") mailSendCmd.Flags().IntVar(&mailPriority, "priority", 2, "Message priority (0=urgent, 1=high, 2=normal, 3=low, 4=backlog)") mailSendCmd.Flags().BoolVar(&mailUrgent, "urgent", false, "Set priority=0 (urgent)") mailSendCmd.Flags().StringVar(&mailType, "type", "notification", "Message type (task, scavenge, notification, reply)") mailSendCmd.Flags().StringVar(&mailReplyTo, "reply-to", "", "Message ID this is replying to") mailSendCmd.Flags().BoolVarP(&mailNotify, "notify", "n", false, "Send tmux notification to recipient") mailSendCmd.Flags().BoolVar(&mailPinned, "pinned", false, "Pin message (for handoff context that persists)") mailSendCmd.Flags().BoolVar(&mailWisp, "wisp", true, "Send as wisp (ephemeral, default)") mailSendCmd.Flags().BoolVar(&mailPermanent, "permanent", false, "Send as permanent (not ephemeral, synced to remote)") mailSendCmd.Flags().BoolVar(&mailSendSelf, "self", false, "Send to self (auto-detect from cwd)") mailSendCmd.Flags().StringArrayVar(&mailCC, "cc", nil, "CC recipients (can be used multiple times)") _ = mailSendCmd.MarkFlagRequired("subject") // cobra flags: error only at runtime if missing // Inbox flags mailInboxCmd.Flags().BoolVar(&mailInboxJSON, "json", false, "Output as JSON") mailInboxCmd.Flags().BoolVarP(&mailInboxUnread, "unread", "u", false, "Show only unread messages") mailInboxCmd.Flags().StringVar(&mailInboxIdentity, "identity", "", "Explicit identity for inbox (e.g., greenplace/Toast)") mailInboxCmd.Flags().StringVar(&mailInboxIdentity, "address", "", "Alias for --identity") // Read flags mailReadCmd.Flags().BoolVar(&mailReadJSON, "json", false, "Output as JSON") // Check flags mailCheckCmd.Flags().BoolVar(&mailCheckInject, "inject", false, "Output format for Claude Code hooks") mailCheckCmd.Flags().BoolVar(&mailCheckJSON, "json", false, "Output as JSON") mailCheckCmd.Flags().StringVar(&mailCheckIdentity, "identity", "", "Explicit identity for inbox (e.g., greenplace/Toast)") mailCheckCmd.Flags().StringVar(&mailCheckIdentity, "address", "", "Alias for --identity") // Thread flags mailThreadCmd.Flags().BoolVar(&mailThreadJSON, "json", false, "Output as JSON") // Reply flags mailReplyCmd.Flags().StringVarP(&mailReplySubject, "subject", "s", "", "Override reply subject (default: Re: )") mailReplyCmd.Flags().StringVarP(&mailReplyMessage, "message", "m", "", "Reply message body (required)") mailReplyCmd.MarkFlagRequired("message") // Add subcommands mailCmd.AddCommand(mailSendCmd) mailCmd.AddCommand(mailInboxCmd) mailCmd.AddCommand(mailReadCmd) mailCmd.AddCommand(mailPeekCmd) mailCmd.AddCommand(mailDeleteCmd) mailCmd.AddCommand(mailArchiveCmd) mailCmd.AddCommand(mailCheckCmd) mailCmd.AddCommand(mailThreadCmd) mailCmd.AddCommand(mailReplyCmd) rootCmd.AddCommand(mailCmd) } 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() } // 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) } // 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 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, ", ")) } if msg.Type != mail.TypeNotification { fmt.Printf(" Type: %s\n", msg.Type) } return nil } func runMailInbox(cmd *cobra.Command, args []string) error { // Determine which inbox to check (priority: --identity flag, positional arg, auto-detect) address := "" if mailInboxIdentity != "" { address = mailInboxIdentity } else if len(args) > 0 { address = args[0] } else { address = detectSender() } // 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) } // Get mailbox router := mail.NewRouter(workDir) mailbox, err := router.GetMailbox(address) if err != nil { return fmt.Errorf("getting mailbox: %w", err) } // Get messages var messages []*mail.Message if mailInboxUnread { messages, err = mailbox.ListUnread() } else { messages, err = mailbox.List() } if err != nil { return fmt.Errorf("listing messages: %w", err) } // JSON output if mailInboxJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(messages) } // Human-readable output total, unread, _ := mailbox.Count() fmt.Printf("%s Inbox: %s (%d messages, %d unread)\n\n", style.Bold.Render("📬"), address, total, unread) if len(messages) == 0 { fmt.Printf(" %s\n", style.Dim.Render("(no messages)")) return nil } for _, msg := range messages { readMarker := "●" if msg.Read { readMarker = "○" } typeMarker := "" if msg.Type != "" && msg.Type != mail.TypeNotification { typeMarker = fmt.Sprintf(" [%s]", msg.Type) } priorityMarker := "" if msg.Priority == mail.PriorityHigh || msg.Priority == mail.PriorityUrgent { priorityMarker = " " + style.Bold.Render("!") } wispMarker := "" if msg.Wisp { wispMarker = " " + style.Dim.Render("(wisp)") } fmt.Printf(" %s %s%s%s%s\n", readMarker, msg.Subject, typeMarker, priorityMarker, wispMarker) fmt.Printf(" %s from %s\n", style.Dim.Render(msg.ID), msg.From) fmt.Printf(" %s\n", style.Dim.Render(msg.Timestamp.Format("2006-01-02 15:04"))) } return nil } func runMailRead(cmd *cobra.Command, args []string) error { msgID := args[0] // Determine which inbox address := detectSender() // 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) } // Get mailbox and message router := mail.NewRouter(workDir) mailbox, err := router.GetMailbox(address) if err != nil { return fmt.Errorf("getting mailbox: %w", err) } msg, err := mailbox.Get(msgID) if err != nil { return fmt.Errorf("getting message: %w", err) } // Note: We intentionally do NOT mark as read/ack on read. // User must explicitly delete/ack the message. // This preserves handoff messages for reference. // JSON output if mailReadJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(msg) } // Human-readable output priorityStr := "" if msg.Priority == mail.PriorityUrgent { priorityStr = " " + style.Bold.Render("[URGENT]") } else if msg.Priority == mail.PriorityHigh { priorityStr = " " + style.Bold.Render("[HIGH PRIORITY]") } typeStr := "" if msg.Type != "" && msg.Type != mail.TypeNotification { typeStr = fmt.Sprintf(" [%s]", msg.Type) } fmt.Printf("%s %s%s%s\n\n", style.Bold.Render("Subject:"), msg.Subject, typeStr, priorityStr) fmt.Printf("From: %s\n", msg.From) fmt.Printf("To: %s\n", msg.To) fmt.Printf("Date: %s\n", msg.Timestamp.Format("2006-01-02 15:04:05")) fmt.Printf("ID: %s\n", style.Dim.Render(msg.ID)) if msg.ThreadID != "" { fmt.Printf("Thread: %s\n", style.Dim.Render(msg.ThreadID)) } if msg.ReplyTo != "" { fmt.Printf("Reply-To: %s\n", style.Dim.Render(msg.ReplyTo)) } if msg.Body != "" { fmt.Printf("\n%s\n", msg.Body) } return nil } func runMailPeek(cmd *cobra.Command, args []string) error { // Determine which inbox address := detectSender() // All mail uses town beads (two-level architecture) workDir, err := findMailWorkDir() if err != nil { return NewSilentExit(1) // Silent exit - no workspace } // Get mailbox router := mail.NewRouter(workDir) mailbox, err := router.GetMailbox(address) if err != nil { return NewSilentExit(1) // Silent exit - can't access mailbox } // Get unread messages messages, err := mailbox.ListUnread() if err != nil || len(messages) == 0 { return NewSilentExit(1) // Silent exit - no unread } // Show first unread message msg := messages[0] // Header with priority indicator priorityStr := "" if msg.Priority == mail.PriorityUrgent { priorityStr = " [URGENT]" } else if msg.Priority == mail.PriorityHigh { priorityStr = " [!]" } fmt.Printf("📬 %s%s\n", msg.Subject, priorityStr) fmt.Printf("From: %s\n", msg.From) fmt.Printf("ID: %s\n\n", msg.ID) // Body preview (truncate long bodies) if msg.Body != "" { body := msg.Body // Truncate to ~500 chars for popup display if len(body) > 500 { body = body[:500] + "\n..." } fmt.Print(body) if !strings.HasSuffix(body, "\n") { fmt.Println() } } // Show count if more messages if len(messages) > 1 { fmt.Printf("\n%s\n", style.Dim.Render(fmt.Sprintf("(+%d more unread)", len(messages)-1))) } return nil } func runMailDelete(cmd *cobra.Command, args []string) error { msgID := args[0] // Determine which inbox address := detectSender() // 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) } // Get mailbox router := mail.NewRouter(workDir) mailbox, err := router.GetMailbox(address) if err != nil { return fmt.Errorf("getting mailbox: %w", err) } if err := mailbox.Delete(msgID); err != nil { return fmt.Errorf("deleting message: %w", err) } fmt.Printf("%s Message deleted\n", style.Bold.Render("✓")) return nil } func runMailArchive(cmd *cobra.Command, args []string) error { msgID := args[0] // Determine which inbox address := detectSender() // 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) } // Get mailbox router := mail.NewRouter(workDir) mailbox, err := router.GetMailbox(address) if err != nil { return fmt.Errorf("getting mailbox: %w", err) } if err := mailbox.Delete(msgID); err != nil { return fmt.Errorf("archiving message: %w", err) } fmt.Printf("%s Message archived\n", style.Bold.Render("✓")) return nil } // findMailWorkDir returns the town root for all mail operations. // // Two-level beads architecture: // - Town beads (~/gt/.beads/): ALL mail and coordination // - Clone beads (/crew/*/.beads/): Project issues only // // Mail ALWAYS uses town beads, regardless of sender or recipient address. // This ensures messages are visible to all agents in the town. func findMailWorkDir() (string, error) { return workspace.FindFromCwdOrError() } // findLocalBeadsDir finds the nearest .beads directory by walking up from CWD. // Used for project work (molecules, issue creation) that uses clone beads. func findLocalBeadsDir() (string, error) { cwd, err := os.Getwd() if err != nil { return "", err } path := cwd for { if _, err := os.Stat(filepath.Join(path, ".beads")); err == nil { return path, nil } parent := filepath.Dir(path) if parent == path { break // Reached root } path = parent } return "", fmt.Errorf("no .beads directory found") } // detectSender determines the current context's address. // Priority: // 1. GT_ROLE env var → use the role-based identity (agent session) // 2. No GT_ROLE → try cwd-based detection (witness/refinery/polecat/crew directories) // 3. No match → return "overseer" (human at terminal) // // All Gas Town agents run in tmux sessions with GT_ROLE set at spawn. // However, cwd-based detection is also tried to support running commands // from agent directories without GT_ROLE set (e.g., debugging sessions). func detectSender() string { // Check GT_ROLE first (authoritative for agent sessions) role := os.Getenv("GT_ROLE") if role != "" { // Agent session - build address from role and context return detectSenderFromRole(role) } // No GT_ROLE - try cwd-based detection, defaults to overseer if not in agent directory return detectSenderFromCwd() } // detectSenderFromRole builds an address from the GT_ROLE and related env vars. // GT_ROLE can be either a simple role name ("crew", "polecat") or a full address // ("greenplace/crew/joe") depending on how the session was started. // // If GT_ROLE is a simple name but required env vars (GT_RIG, GT_POLECAT, etc.) // are missing, falls back to cwd-based detection. This could return "overseer" // if cwd doesn't match any known agent path - a misconfigured agent session. func detectSenderFromRole(role string) string { rig := os.Getenv("GT_RIG") // Check if role is already a full address (contains /) if strings.Contains(role, "/") { // GT_ROLE is already a full address, use it directly return role } // GT_ROLE is a simple role name, build the full address switch role { case "mayor": return "mayor/" case "deacon": return "deacon/" case "polecat": polecat := os.Getenv("GT_POLECAT") if rig != "" && polecat != "" { return fmt.Sprintf("%s/%s", rig, polecat) } // Fallback to cwd detection for polecats return detectSenderFromCwd() case "crew": crew := os.Getenv("GT_CREW") if rig != "" && crew != "" { return fmt.Sprintf("%s/crew/%s", rig, crew) } // Fallback to cwd detection for crew return detectSenderFromCwd() case "witness": if rig != "" { return fmt.Sprintf("%s/witness", rig) } return detectSenderFromCwd() case "refinery": if rig != "" { return fmt.Sprintf("%s/refinery", rig) } return detectSenderFromCwd() default: // Unknown role, try cwd detection return detectSenderFromCwd() } } // detectSenderFromCwd is the legacy cwd-based detection for edge cases. func detectSenderFromCwd() string { cwd, err := os.Getwd() if err != nil { return "overseer" } // If in a rig's polecats directory, extract address (format: rig/polecats/name) if strings.Contains(cwd, "/polecats/") { parts := strings.Split(cwd, "/polecats/") if len(parts) >= 2 { rigPath := parts[0] polecatPath := strings.Split(parts[1], "/")[0] rigName := filepath.Base(rigPath) return fmt.Sprintf("%s/polecats/%s", rigName, polecatPath) } } // If in a rig's crew directory, extract address (format: rig/crew/name) if strings.Contains(cwd, "/crew/") { parts := strings.Split(cwd, "/crew/") if len(parts) >= 2 { rigPath := parts[0] crewName := strings.Split(parts[1], "/")[0] rigName := filepath.Base(rigPath) return fmt.Sprintf("%s/crew/%s", rigName, crewName) } } // If in a rig's refinery directory, extract address (format: rig/refinery) if strings.Contains(cwd, "/refinery") { parts := strings.Split(cwd, "/refinery") if len(parts) >= 1 { rigName := filepath.Base(parts[0]) return fmt.Sprintf("%s/refinery", rigName) } } // If in a rig's witness directory, extract address (format: rig/witness) if strings.Contains(cwd, "/witness") { parts := strings.Split(cwd, "/witness") if len(parts) >= 1 { rigName := filepath.Base(parts[0]) return fmt.Sprintf("%s/witness", rigName) } } // Default to overseer (human) return "overseer" } func runMailCheck(cmd *cobra.Command, args []string) error { // Determine which inbox (priority: --identity flag, auto-detect) address := "" if mailCheckIdentity != "" { address = mailCheckIdentity } else { address = detectSender() } // All mail uses town beads (two-level architecture) workDir, err := findMailWorkDir() if err != nil { if mailCheckInject { // Inject mode: always exit 0, silent on error return nil } return fmt.Errorf("not in a Gas Town workspace: %w", err) } // Get mailbox router := mail.NewRouter(workDir) mailbox, err := router.GetMailbox(address) if err != nil { if mailCheckInject { return nil } return fmt.Errorf("getting mailbox: %w", err) } // Count unread _, unread, err := mailbox.Count() if err != nil { if mailCheckInject { return nil } return fmt.Errorf("counting messages: %w", err) } // JSON output if mailCheckJSON { result := map[string]interface{}{ "address": address, "unread": unread, "has_new": unread > 0, } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(result) } // Inject mode: output system-reminder if mail exists if mailCheckInject { if unread > 0 { // Get subjects for context messages, _ := mailbox.ListUnread() var subjects []string for _, msg := range messages { subjects = append(subjects, fmt.Sprintf("- %s from %s: %s", msg.ID, msg.From, msg.Subject)) } fmt.Println("") fmt.Printf("You have %d unread message(s) in your inbox.\n\n", unread) for _, s := range subjects { fmt.Println(s) } fmt.Println() fmt.Println("Run 'gt mail inbox' to see your messages, or 'gt mail read ' for a specific message.") fmt.Println("") } return nil } // Normal mode if unread > 0 { fmt.Printf("%s %d unread message(s)\n", style.Bold.Render("📬"), unread) return NewSilentExit(0) } fmt.Println("No new mail") return NewSilentExit(1) } func runMailThread(cmd *cobra.Command, args []string) error { threadID := args[0] // 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 which inbox address := detectSender() // Get mailbox and thread messages router := mail.NewRouter(workDir) mailbox, err := router.GetMailbox(address) if err != nil { return fmt.Errorf("getting mailbox: %w", err) } messages, err := mailbox.ListByThread(threadID) if err != nil { return fmt.Errorf("getting thread: %w", err) } // JSON output if mailThreadJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(messages) } // Human-readable output fmt.Printf("%s Thread: %s (%d messages)\n\n", style.Bold.Render("🧵"), threadID, len(messages)) if len(messages) == 0 { fmt.Printf(" %s\n", style.Dim.Render("(no messages in thread)")) return nil } for i, msg := range messages { typeMarker := "" if msg.Type != "" && msg.Type != mail.TypeNotification { typeMarker = fmt.Sprintf(" [%s]", msg.Type) } priorityMarker := "" if msg.Priority == mail.PriorityHigh || msg.Priority == mail.PriorityUrgent { priorityMarker = " " + style.Bold.Render("!") } if i > 0 { fmt.Printf(" %s\n", style.Dim.Render("│")) } fmt.Printf(" %s %s%s%s\n", style.Bold.Render("●"), msg.Subject, typeMarker, priorityMarker) fmt.Printf(" %s from %s to %s\n", style.Dim.Render(msg.ID), msg.From, msg.To) fmt.Printf(" %s\n", style.Dim.Render(msg.Timestamp.Format("2006-01-02 15:04"))) if msg.Body != "" { fmt.Printf(" %s\n", msg.Body) } } return nil } func runMailReply(cmd *cobra.Command, args []string) error { msgID := args[0] // 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 current address from := detectSender() // Get the original message router := mail.NewRouter(workDir) mailbox, err := router.GetMailbox(from) if err != nil { return fmt.Errorf("getting mailbox: %w", err) } original, err := mailbox.Get(msgID) if err != nil { return fmt.Errorf("getting message: %w", err) } // Build reply subject subject := mailReplySubject if subject == "" { if strings.HasPrefix(original.Subject, "Re: ") { subject = original.Subject } else { subject = "Re: " + original.Subject } } // Create reply message reply := &mail.Message{ From: from, To: original.From, // Reply to sender Subject: subject, Body: mailReplyMessage, Type: mail.TypeReply, Priority: mail.PriorityNormal, ReplyTo: msgID, ThreadID: original.ThreadID, } // If original has no thread ID, create one if reply.ThreadID == "" { reply.ThreadID = generateThreadID() } // Send the reply if err := router.Send(reply); err != nil { return fmt.Errorf("sending reply: %w", err) } fmt.Printf("%s Reply sent to %s\n", style.Bold.Render("✓"), original.From) fmt.Printf(" Subject: %s\n", subject) if original.ThreadID != "" { fmt.Printf(" Thread: %s\n", style.Dim.Render(original.ThreadID)) } 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) }