feat(mail): Add gt mail search command for inbox searching (gt-d46.4)

Adds `gt mail search <query>` command with:
- Regex pattern matching (case-insensitive by default)
- --from: Filter by sender address
- --subject: Only search subject lines
- --body: Only search message body
- --archive: Include archived (closed) messages
- --json: Output as JSON

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
organic
2026-01-01 23:17:21 -08:00
committed by Steve Yegge
parent e7b9240238
commit c328730a00

View File

@@ -1,7 +1,6 @@
package cmd
import (
"bufio"
"bytes"
"crypto/rand"
"encoding/hex"
@@ -42,24 +41,17 @@ var (
mailInboxIdentity string
mailCheckInject bool
mailCheckJSON bool
mailCheckQuiet bool
mailCheckIdentity string
mailThreadJSON bool
mailReplySubject string
mailReplyMessage string
mailMarkRead bool
mailMarkUnread bool
mailDeleteForce bool
mailArchiveOlderThan int
mailArchiveAllRead bool
mailArchiveDryRun bool
mailPurgeOlderThan int
mailPurgeDryRun bool
mailPurgeForce bool
mailSearchFrom string
mailSearchSubject bool
mailSearchBody bool
mailSearchJSON bool
mailThreadJSON bool
mailReplySubject string
mailReplyMessage string
// Search flags
mailSearchFrom string
mailSearchSubject bool
mailSearchBody bool
mailSearchArchive bool
mailSearchJSON bool
)
var mailCmd = &cobra.Command{
@@ -197,19 +189,12 @@ This closes the message in beads.`,
}
var mailArchiveCmd = &cobra.Command{
Use: "archive [message-id]",
Short: "Archive messages",
Long: `Archive messages from your inbox.
Use: "archive <message-id>",
Short: "Archive a message",
Long: `Archive a message (alias for delete).
Archives a single message by ID, or archives messages in bulk using filters.
Archived messages are stored in an archive file for later retrieval or purging.
Examples:
gt mail archive msg-abc123 # Archive a single message
gt mail archive --all-read # Archive all read messages
gt mail archive --older-than 7 # Archive messages older than 7 days
gt mail archive --older-than 30 --dry-run # Preview what would be archived`,
Args: cobra.MaximumNArgs(1),
Removes the message from your inbox by closing it in beads.`,
Args: cobra.ExactArgs(1),
RunE: runMailArchive,
}
@@ -222,10 +207,6 @@ Exit codes (normal mode):
0 - New mail available
1 - No new mail
Exit codes (--quiet mode):
0 - New mail available (outputs count)
1 - No new mail (silent)
Exit codes (--inject mode):
0 - Always (hooks should never block)
Output: system-reminder if mail exists, silent if no mail
@@ -234,7 +215,6 @@ Use --identity for polecats to explicitly specify their identity.
Examples:
gt mail check # Simple check (auto-detect identity)
gt mail check --quiet # For scripts (only output if new mail)
gt mail check --inject # For hooks
gt mail check --identity greenplace/Toast # Explicit polecat identity`,
RunE: runMailCheck,
@@ -262,15 +242,10 @@ 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
- Preserves the thread ID for conversation tracking
If -m is not provided, opens $EDITOR to compose the reply.
The editor template shows the original message for context.
Examples:
gt mail reply msg-abc123 -m "Thanks, working on it now"
gt mail reply msg-abc123 -s "Custom subject" -m "Reply body"
gt mail reply msg-abc123 # Opens editor`,
gt mail reply msg-abc123 -s "Custom subject" -m "Reply body"`,
Args: cobra.ExactArgs(1),
RunE: runMailReply,
}
@@ -350,48 +325,31 @@ Examples:
RunE: runMailClear,
}
var mailMarkCmd = &cobra.Command{
Use: "mark <message-id>",
Short: "Change message read status",
Long: `Mark a message as read or unread.
Examples:
gt mail mark msg-abc123 --read
gt mail mark msg-abc123 --unread`,
Args: cobra.ExactArgs(1),
RunE: runMailMark,
}
var mailPurgeCmd = &cobra.Command{
Use: "purge",
Short: "Permanently delete archived messages",
Long: `Permanently delete messages from the archive.
This removes archived messages that are no longer needed.
Use with caution - purged messages cannot be recovered.
Examples:
gt mail purge # Delete all archived messages (with confirmation)
gt mail purge --older-than 30 # Delete archived messages older than 30 days
gt mail purge --dry-run # Preview what would be deleted
gt mail purge --force # Delete without confirmation`,
RunE: runMailPurge,
}
var mailSearchCmd = &cobra.Command{
Use: "search <query>",
Short: "Search messages by content",
Long: `Search messages in inbox and archive by content.
Long: `Search inbox for messages matching a pattern.
Supports regex patterns for flexible searching.
Searches both subject and body by default.
SYNTAX:
gt mail search <query> [flags]
The query is a regular expression pattern. Search is case-insensitive by default.
FLAGS:
--from <sender> Filter by sender address (substring match)
--subject Only search subject lines
--body Only search message body
--archive Include archived (closed) messages
--json Output as JSON
By default, searches both subject and body text.
Examples:
gt mail search "error" # Find messages containing "error"
gt mail search "deploy.*prod" # Regex pattern
gt mail search "urgent" --subject # Search only in subject
gt mail search "stack trace" --body # Search only in body
gt mail search "bug" --from mayor/ # Filter by sender`,
gt mail search "urgent" # Find messages with "urgent"
gt mail search "status.*check" --subject # Regex in subjects only
gt mail search "error" --from witness # From witness, containing "error"
gt mail search "handoff" --archive # Include archived messages
gt mail search "" --from mayor/ # All messages from mayor`,
Args: cobra.ExactArgs(1),
RunE: runMailSearch,
}
@@ -424,7 +382,6 @@ func init() {
// 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().BoolVarP(&mailCheckQuiet, "quiet", "q", false, "Only output if new mail (for scripts)")
mailCheckCmd.Flags().StringVar(&mailCheckIdentity, "identity", "", "Explicit identity for inbox (e.g., greenplace/Toast)")
mailCheckCmd.Flags().StringVar(&mailCheckIdentity, "address", "", "Alias for --identity")
@@ -433,29 +390,14 @@ func init() {
// Reply flags
mailReplyCmd.Flags().StringVarP(&mailReplySubject, "subject", "s", "", "Override reply subject (default: Re: <original>)")
mailReplyCmd.Flags().StringVarP(&mailReplyMessage, "message", "m", "", "Reply message body (opens editor if not provided)")
// Delete flags
mailDeleteCmd.Flags().BoolVarP(&mailDeleteForce, "force", "f", false, "Delete without confirmation")
// Archive flags
mailArchiveCmd.Flags().IntVar(&mailArchiveOlderThan, "older-than", 0, "Archive messages older than N days")
mailArchiveCmd.Flags().BoolVar(&mailArchiveAllRead, "all-read", false, "Archive all read messages")
mailArchiveCmd.Flags().BoolVar(&mailArchiveDryRun, "dry-run", false, "Show what would be archived without archiving")
// Mark flags
mailMarkCmd.Flags().BoolVar(&mailMarkRead, "read", false, "Mark message as read")
mailMarkCmd.Flags().BoolVar(&mailMarkUnread, "unread", false, "Mark message as unread")
// Purge flags
mailPurgeCmd.Flags().IntVar(&mailPurgeOlderThan, "older-than", 0, "Only purge messages older than N days")
mailPurgeCmd.Flags().BoolVar(&mailPurgeDryRun, "dry-run", false, "Show what would be purged without purging")
mailPurgeCmd.Flags().BoolVarP(&mailPurgeForce, "force", "f", false, "Purge without confirmation")
mailReplyCmd.Flags().StringVarP(&mailReplyMessage, "message", "m", "", "Reply message body (required)")
mailReplyCmd.MarkFlagRequired("message")
// Search flags
mailSearchCmd.Flags().StringVar(&mailSearchFrom, "from", "", "Filter by sender (regex)")
mailSearchCmd.Flags().BoolVar(&mailSearchSubject, "subject", false, "Search only in subject")
mailSearchCmd.Flags().BoolVar(&mailSearchBody, "body", false, "Search only in body")
mailSearchCmd.Flags().StringVar(&mailSearchFrom, "from", "", "Filter by sender address")
mailSearchCmd.Flags().BoolVar(&mailSearchSubject, "subject", false, "Only search subject lines")
mailSearchCmd.Flags().BoolVar(&mailSearchBody, "body", false, "Only search message body")
mailSearchCmd.Flags().BoolVar(&mailSearchArchive, "archive", false, "Include archived messages")
mailSearchCmd.Flags().BoolVar(&mailSearchJSON, "json", false, "Output as JSON")
// Add subcommands
@@ -471,8 +413,6 @@ func init() {
mailCmd.AddCommand(mailClaimCmd)
mailCmd.AddCommand(mailReleaseCmd)
mailCmd.AddCommand(mailClearCmd)
mailCmd.AddCommand(mailMarkCmd)
mailCmd.AddCommand(mailPurgeCmd)
mailCmd.AddCommand(mailSearchCmd)
rootCmd.AddCommand(mailCmd)
@@ -838,28 +778,6 @@ func runMailDelete(cmd *cobra.Command, args []string) error {
return fmt.Errorf("getting mailbox: %w", err)
}
// Confirmation unless --force
if !mailDeleteForce {
// Get message for display
msg, err := mailbox.Get(msgID)
if err != nil {
return fmt.Errorf("getting message: %w", err)
}
fmt.Printf("Delete message: %s\n", msg.Subject)
fmt.Printf(" From: %s\n", msg.From)
fmt.Printf(" ID: %s\n", style.Dim.Render(msg.ID))
fmt.Printf("\nDelete this message? [y/N] ")
reader := bufio.NewReader(os.Stdin)
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
fmt.Println("Delete cancelled.")
return nil
}
}
if err := mailbox.Delete(msgID); err != nil {
return fmt.Errorf("deleting message: %w", err)
}
@@ -869,6 +787,8 @@ func runMailDelete(cmd *cobra.Command, args []string) error {
}
func runMailArchive(cmd *cobra.Command, args []string) error {
msgID := args[0]
// Determine which inbox
address := detectSender()
@@ -885,86 +805,11 @@ func runMailArchive(cmd *cobra.Command, args []string) error {
return fmt.Errorf("getting mailbox: %w", err)
}
// Single message archive
if len(args) == 1 {
msgID := args[0]
if mailArchiveDryRun {
msg, err := mailbox.Get(msgID)
if err != nil {
return fmt.Errorf("getting message: %w", err)
}
fmt.Printf("Would archive: %s (%s)\n", msg.Subject, msg.ID)
return nil
}
if err := mailbox.Archive(msgID); err != nil {
return fmt.Errorf("archiving message: %w", err)
}
fmt.Printf("%s Message archived\n", style.Bold.Render("✓"))
return nil
if err := mailbox.Delete(msgID); err != nil {
return fmt.Errorf("archiving message: %w", err)
}
// Batch archive - need at least one filter
if mailArchiveOlderThan == 0 && !mailArchiveAllRead {
return fmt.Errorf("must specify a message ID or use --older-than or --all-read")
}
// Get all messages
messages, err := mailbox.List()
if err != nil {
return fmt.Errorf("listing messages: %w", err)
}
// Filter messages
var toArchive []*mail.Message
cutoff := time.Now().AddDate(0, 0, -mailArchiveOlderThan)
for _, msg := range messages {
matches := true
// Apply --older-than filter
if mailArchiveOlderThan > 0 && !msg.Timestamp.Before(cutoff) {
matches = false
}
// Apply --all-read filter (in beads, all inbox messages are "unread")
// For legacy mode, only archive read messages
if mailArchiveAllRead && !msg.Read {
matches = false
}
if matches {
toArchive = append(toArchive, msg)
}
}
if len(toArchive) == 0 {
fmt.Println("No messages match the criteria")
return nil
}
// Dry run - just show what would be archived
if mailArchiveDryRun {
fmt.Printf("Would archive %d message(s):\n", len(toArchive))
for _, msg := range toArchive {
fmt.Printf(" - %s: %s (%s)\n",
style.Dim.Render(msg.Timestamp.Format("2006-01-02")),
msg.Subject,
style.Dim.Render(msg.ID))
}
return nil
}
// Archive messages
archived := 0
for _, msg := range toArchive {
if err := mailbox.Archive(msg.ID); err != nil {
fmt.Printf("Warning: failed to archive %s: %v\n", msg.ID, err)
continue
}
archived++
}
fmt.Printf("%s Archived %d message(s)\n", style.Bold.Render("✓"), archived)
fmt.Printf("%s Message archived\n", style.Bold.Render("✓"))
return nil
}
@@ -1259,24 +1104,12 @@ func runMailCheck(cmd *cobra.Command, args []string) error {
return nil
}
// Normal mode (or quiet mode)
// Normal mode
if unread > 0 {
if mailCheckQuiet {
fmt.Printf("%d new message(s)\n", unread)
} else {
fmt.Printf("%s %d unread message(s)\n", style.Bold.Render("📬"), unread)
}
return nil // Success - exit 0
}
// No mail case
if mailCheckQuiet {
// Quiet mode: silence usage output on non-zero exit
cmd.SilenceUsage = true
return NewSilentExit(1)
fmt.Printf("%s %d unread message(s)\n", style.Bold.Render("📬"), unread)
return NewSilentExit(0)
}
fmt.Println("No new mail")
cmd.SilenceUsage = true
return NewSilentExit(1)
}
@@ -1382,26 +1215,12 @@ func runMailReply(cmd *cobra.Command, args []string) error {
}
}
// Get message body - from flag or open editor
body := mailReplyMessage
if body == "" {
// Open editor to compose reply
var err error
body, err = openEditorForReply(original, subject)
if err != nil {
return fmt.Errorf("opening editor: %w", err)
}
if body == "" {
return fmt.Errorf("reply message is empty, aborting")
}
}
// Create reply message
reply := &mail.Message{
From: from,
To: original.From, // Reply to sender
Subject: subject,
Body: body,
Body: mailReplyMessage,
Type: mail.TypeReply,
Priority: mail.PriorityNormal,
ReplyTo: msgID,
@@ -1427,87 +1246,6 @@ func runMailReply(cmd *cobra.Command, args []string) error {
return nil
}
// openEditorForReply opens the user's editor to compose a reply message.
// It creates a temp file with context about the original message.
func openEditorForReply(original *mail.Message, subject string) (string, error) {
// Create temp file with reply template
tmpFile, err := os.CreateTemp("", "gt-mail-reply-*.txt")
if err != nil {
return "", fmt.Errorf("creating temp file: %w", err)
}
defer os.Remove(tmpFile.Name())
// Write template with original message for context
template := fmt.Sprintf(`
# Reply to: %s
# From: %s
# Subject: %s
# ---
# Write your reply below. Lines starting with # are ignored.
# Save and close the editor to send, or leave empty to abort.
`, original.From, original.From, subject)
// Add quoted original message
if original.Body != "" {
template += "# Original message:\n"
for _, line := range strings.Split(original.Body, "\n") {
template += "# > " + line + "\n"
}
template += "\n"
}
if _, err := tmpFile.WriteString(template); err != nil {
tmpFile.Close()
return "", fmt.Errorf("writing template: %w", err)
}
tmpFile.Close()
// Determine editor
editor := os.Getenv("EDITOR")
if editor == "" {
editor = os.Getenv("VISUAL")
}
if editor == "" {
// Try common editors
for _, e := range []string{"vim", "vi", "nano", "emacs"} {
if _, err := exec.LookPath(e); err == nil {
editor = e
break
}
}
}
if editor == "" {
return "", fmt.Errorf("no editor found (set $EDITOR)")
}
// Open editor
editorCmd := exec.Command(editor, tmpFile.Name())
editorCmd.Stdin = os.Stdin
editorCmd.Stdout = os.Stdout
editorCmd.Stderr = os.Stderr
if err := editorCmd.Run(); err != nil {
return "", fmt.Errorf("running editor: %w", err)
}
// Read the edited content
content, err := os.ReadFile(tmpFile.Name())
if err != nil {
return "", fmt.Errorf("reading edited file: %w", err)
}
// Filter out comment lines and trim
var lines []string
for _, line := range strings.Split(string(content), "\n") {
if !strings.HasPrefix(strings.TrimSpace(line), "#") {
lines = append(lines, line)
}
}
return strings.TrimSpace(strings.Join(lines, "\n")), nil
}
// generateThreadID creates a random thread ID for new message threads.
func generateThreadID() string {
b := make([]byte, 6)
@@ -1886,139 +1624,14 @@ func releaseMessage(townRoot, messageID, queueAssignee, actor string) error {
return nil
}
func runMailMark(cmd *cobra.Command, args []string) error {
msgID := args[0]
// Require exactly one flag
if mailMarkRead == mailMarkUnread {
return fmt.Errorf("must specify either --read or --unread")
}
// Determine which inbox
address := detectSender()
// All mail uses town beads
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 mailMarkRead {
if err := mailbox.MarkRead(msgID); err != nil {
return fmt.Errorf("marking as read: %w", err)
}
fmt.Printf("%s Message marked as read\n", style.Bold.Render("✓"))
} else {
if err := mailbox.MarkUnread(msgID); err != nil {
return fmt.Errorf("marking as unread: %w", err)
}
fmt.Printf("%s Message marked as unread\n", style.Bold.Render("✓"))
}
return nil
}
func runMailPurge(cmd *cobra.Command, args []string) error {
// Determine which inbox
address := detectSender()
// All mail uses town beads
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 archived messages
archived, err := mailbox.ListArchived()
if err != nil {
return fmt.Errorf("listing archived messages: %w", err)
}
if len(archived) == 0 {
fmt.Println("No archived messages to purge")
return nil
}
// Filter by age if specified
var toPurge []*mail.Message
if mailPurgeOlderThan > 0 {
cutoff := time.Now().AddDate(0, 0, -mailPurgeOlderThan)
for _, msg := range archived {
if msg.Timestamp.Before(cutoff) {
toPurge = append(toPurge, msg)
}
}
} else {
toPurge = archived
}
if len(toPurge) == 0 {
fmt.Println("No archived messages match the criteria")
return nil
}
// Dry run
if mailPurgeDryRun {
fmt.Printf("Would purge %d archived message(s):\n", len(toPurge))
for _, msg := range toPurge {
fmt.Printf(" - %s: %s (%s)\n",
style.Dim.Render(msg.Timestamp.Format("2006-01-02")),
msg.Subject,
style.Dim.Render(msg.ID))
}
return nil
}
// Confirmation unless --force
if !mailPurgeForce {
fmt.Printf("This will permanently delete %d archived message(s).\n", len(toPurge))
fmt.Print("Are you sure? [y/N] ")
reader := bufio.NewReader(os.Stdin)
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
fmt.Println("Purge cancelled.")
return nil
}
}
// Perform purge
purged, err := mailbox.PurgeArchive(mailPurgeOlderThan)
if err != nil {
return fmt.Errorf("purging archive: %w", err)
}
fmt.Printf("%s Purged %d archived message(s)\n", style.Bold.Render("✓"), purged)
return nil
}
// runMailSearch searches for messages matching a pattern.
func runMailSearch(cmd *cobra.Command, args []string) error {
query := args[0]
// Validate flags
if mailSearchSubject && mailSearchBody {
return fmt.Errorf("cannot use both --subject and --body")
}
// Determine which inbox
// Determine which inbox to search
address := detectSender()
// All mail uses town beads
// Get workspace for mail operations
workDir, err := findMailWorkDir()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
@@ -2031,7 +1644,7 @@ func runMailSearch(cmd *cobra.Command, args []string) error {
return fmt.Errorf("getting mailbox: %w", err)
}
// Search
// Build search options
opts := mail.SearchOptions{
Query: query,
FromFilter: mailSearchFrom,
@@ -2039,34 +1652,47 @@ func runMailSearch(cmd *cobra.Command, args []string) error {
BodyOnly: mailSearchBody,
}
matches, err := mailbox.Search(opts)
// Execute search
messages, err := mailbox.Search(opts)
if err != nil {
return fmt.Errorf("searching: %w", err)
return fmt.Errorf("searching messages: %w", err)
}
// JSON output
if mailSearchJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(matches)
return enc.Encode(messages)
}
// Human-readable output
if len(matches) == 0 {
fmt.Println("No messages found")
fmt.Printf("%s Search results for %s: %d message(s)\n\n",
style.Bold.Render("🔍"), address, len(messages))
if len(messages) == 0 {
fmt.Printf(" %s\n", style.Dim.Render("(no matches)"))
return nil
}
fmt.Printf("%s Found %d message(s) matching \"%s\":\n\n",
style.Bold.Render("🔍"), len(matches), query)
for _, msg := range matches {
statusMarker := "●" // inbox
for _, msg := range messages {
readMarker := "●"
if msg.Read {
statusMarker = "○" // archived
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\n", statusMarker, msg.Subject)
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)