From 918fcc34fa0057e2d0ff351b127c301e8dfb2544 Mon Sep 17 00:00:00 2001 From: gastown/crew/jack Date: Thu, 1 Jan 2026 20:31:29 -0800 Subject: [PATCH] feat: Add mail mark, purge, search commands and batch archive (gt-d46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gt mail mark --read/--unread: Change message read status - gt mail delete --force: Add confirmation prompt (skip with --force) - gt mail archive: Batch operations with --older-than, --all-read, --dry-run - gt mail purge: Delete archived messages with --older-than, --dry-run, --force - gt mail search : Regex search with --from, --subject, --body, --json 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/mail.go | 405 +++++++++++++++++++++++++++++++++++++-- internal/mail/mailbox.go | 288 ++++++++++++++++++++++++++++ 2 files changed, 680 insertions(+), 13 deletions(-) diff --git a/internal/cmd/mail.go b/internal/cmd/mail.go index ae16aa4f..28ad1e18 100644 --- a/internal/cmd/mail.go +++ b/internal/cmd/mail.go @@ -1,6 +1,7 @@ package cmd import ( + "bufio" "bytes" "crypto/rand" "encoding/hex" @@ -42,9 +43,22 @@ var ( mailCheckInject bool mailCheckJSON bool mailCheckIdentity string - mailThreadJSON bool - mailReplySubject string - mailReplyMessage 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 ) var mailCmd = &cobra.Command{ @@ -182,12 +196,19 @@ This closes the message in beads.`, } var mailArchiveCmd = &cobra.Command{ - Use: "archive ", - Short: "Archive a message", - Long: `Archive a message (alias for delete). + Use: "archive [message-id]", + Short: "Archive messages", + Long: `Archive messages from your inbox. -Removes the message from your inbox by closing it in beads.`, - Args: cobra.ExactArgs(1), +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), RunE: runMailArchive, } @@ -318,6 +339,52 @@ Examples: RunE: runMailClear, } +var mailMarkCmd = &cobra.Command{ + Use: "mark ", + 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 ", + Short: "Search messages by content", + Long: `Search messages in inbox and archive by content. + +Supports regex patterns for flexible searching. +Searches both subject and body by default. + +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`, + Args: cobra.ExactArgs(1), + RunE: runMailSearch, +} + func init() { // Send flags mailSendCmd.Flags().StringVarP(&mailSubject, "subject", "s", "", "Message subject (required)") @@ -357,6 +424,29 @@ func init() { mailReplyCmd.Flags().StringVarP(&mailReplyMessage, "message", "m", "", "Reply message body (required)") mailReplyCmd.MarkFlagRequired("message") + // 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") + + // 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().BoolVar(&mailSearchJSON, "json", false, "Output as JSON") + // Add subcommands mailCmd.AddCommand(mailSendCmd) mailCmd.AddCommand(mailInboxCmd) @@ -370,6 +460,9 @@ func init() { mailCmd.AddCommand(mailClaimCmd) mailCmd.AddCommand(mailReleaseCmd) mailCmd.AddCommand(mailClearCmd) + mailCmd.AddCommand(mailMarkCmd) + mailCmd.AddCommand(mailPurgeCmd) + mailCmd.AddCommand(mailSearchCmd) rootCmd.AddCommand(mailCmd) } @@ -734,6 +827,28 @@ 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) } @@ -743,8 +858,6 @@ func runMailDelete(cmd *cobra.Command, args []string) error { } func runMailArchive(cmd *cobra.Command, args []string) error { - msgID := args[0] - // Determine which inbox address := detectSender() @@ -761,11 +874,86 @@ func runMailArchive(cmd *cobra.Command, args []string) error { return fmt.Errorf("getting mailbox: %w", err) } - if err := mailbox.Delete(msgID); err != nil { - return fmt.Errorf("archiving message: %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 } - fmt.Printf("%s Message archived\n", style.Bold.Render("✓")) + // 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) return nil } @@ -1579,3 +1767,194 @@ 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 +} + +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 + 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) + } + + // Search + opts := mail.SearchOptions{ + Query: query, + FromFilter: mailSearchFrom, + SubjectOnly: mailSearchSubject, + BodyOnly: mailSearchBody, + } + + matches, err := mailbox.Search(opts) + if err != nil { + return fmt.Errorf("searching: %w", err) + } + + // JSON output + if mailSearchJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(matches) + } + + // Human-readable output + if len(matches) == 0 { + fmt.Println("No messages found") + 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 + if msg.Read { + statusMarker = "○" // archived + } + + fmt.Printf(" %s %s\n", statusMarker, msg.Subject) + 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 +} diff --git a/internal/mail/mailbox.go b/internal/mail/mailbox.go index e4fc0108..fae31ee9 100644 --- a/internal/mail/mailbox.go +++ b/internal/mail/mailbox.go @@ -5,15 +5,21 @@ import ( "bytes" "encoding/json" "errors" + "fmt" "os" "os/exec" "path/filepath" + "regexp" "sort" "strings" + "time" "github.com/steveyegge/gastown/internal/beads" ) +// timeNow is a function that returns the current time. It can be overridden in tests. +var timeNow = time.Now + // Common errors var ( ErrMessageNotFound = errors.New("message not found") @@ -360,6 +366,57 @@ func (m *Mailbox) markReadLegacy(id string) error { return m.rewriteLegacy(messages) } +// MarkUnread marks a message as unread (reopens in beads). +func (m *Mailbox) MarkUnread(id string) error { + if m.legacy { + return m.markUnreadLegacy(id) + } + return m.markUnreadBeads(id) +} + +func (m *Mailbox) markUnreadBeads(id string) error { + cmd := exec.Command("bd", "reopen", id) + cmd.Dir = m.workDir + cmd.Env = append(cmd.Environ(), "BEADS_DIR="+m.beadsDir) + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + errMsg := strings.TrimSpace(stderr.String()) + if strings.Contains(errMsg, "not found") { + return ErrMessageNotFound + } + if errMsg != "" { + return errors.New(errMsg) + } + return err + } + + return nil +} + +func (m *Mailbox) markUnreadLegacy(id string) error { + messages, err := m.List() + if err != nil { + return err + } + + found := false + for _, msg := range messages { + if msg.ID == id { + msg.Read = false + found = true + } + } + + if !found { + return ErrMessageNotFound + } + + return m.rewriteLegacy(messages) +} + // Delete removes a message. func (m *Mailbox) Delete(id string) error { if m.legacy { @@ -391,6 +448,237 @@ func (m *Mailbox) deleteLegacy(id string) error { return m.rewriteLegacy(filtered) } +// Archive moves a message to the archive file and removes it from inbox. +func (m *Mailbox) Archive(id string) error { + // Get the message first + msg, err := m.Get(id) + if err != nil { + return err + } + + // Append to archive file + if err := m.appendToArchive(msg); err != nil { + return err + } + + // Delete from inbox + return m.Delete(id) +} + +// ArchivePath returns the path to the archive file. +func (m *Mailbox) ArchivePath() string { + if m.legacy { + return m.path + ".archive" + } + // For beads, use archive.jsonl in the same directory as beads + return filepath.Join(m.beadsDir, "archive.jsonl") +} + +func (m *Mailbox) appendToArchive(msg *Message) error { + archivePath := m.ArchivePath() + + // Ensure directory exists + dir := filepath.Dir(archivePath) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + // Open for append + file, err := os.OpenFile(archivePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer func() { _ = file.Close() }() + + data, err := json.Marshal(msg) + if err != nil { + return err + } + + _, err = file.WriteString(string(data) + "\n") + return err +} + +// ListArchived returns all messages in the archive file. +func (m *Mailbox) ListArchived() ([]*Message, error) { + archivePath := m.ArchivePath() + + file, err := os.Open(archivePath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer func() { _ = file.Close() }() + + var messages []*Message + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + var msg Message + if err := json.Unmarshal([]byte(line), &msg); err != nil { + continue // Skip malformed lines + } + messages = append(messages, &msg) + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return messages, nil +} + +// PurgeArchive removes messages from the archive, optionally filtering by age. +// If olderThanDays is 0, removes all archived messages. +func (m *Mailbox) PurgeArchive(olderThanDays int) (int, error) { + messages, err := m.ListArchived() + if err != nil { + return 0, err + } + + if len(messages) == 0 { + return 0, nil + } + + // If no age filter, remove all + if olderThanDays <= 0 { + if err := os.Remove(m.ArchivePath()); err != nil && !os.IsNotExist(err) { + return 0, err + } + return len(messages), nil + } + + // Filter by age + cutoff := timeNow().AddDate(0, 0, -olderThanDays) + var keep []*Message + purged := 0 + + for _, msg := range messages { + if msg.Timestamp.Before(cutoff) { + purged++ + } else { + keep = append(keep, msg) + } + } + + // Rewrite archive with remaining messages + if len(keep) == 0 { + if err := os.Remove(m.ArchivePath()); err != nil && !os.IsNotExist(err) { + return 0, err + } + } else { + if err := m.rewriteArchive(keep); err != nil { + return 0, err + } + } + + return purged, nil +} + +func (m *Mailbox) rewriteArchive(messages []*Message) error { + archivePath := m.ArchivePath() + tmpPath := archivePath + ".tmp" + + file, err := os.Create(tmpPath) + if err != nil { + return err + } + + for _, msg := range messages { + data, err := json.Marshal(msg) + if err != nil { + _ = file.Close() + _ = os.Remove(tmpPath) + return err + } + _, _ = file.WriteString(string(data) + "\n") + } + + if err := file.Close(); err != nil { + _ = os.Remove(tmpPath) + return err + } + + return os.Rename(tmpPath, archivePath) +} + +// SearchOptions specifies search parameters. +type SearchOptions struct { + Query string // Regex pattern to search for + FromFilter string // Optional: only match messages from this sender + SubjectOnly bool // Only search subject + BodyOnly bool // Only search body +} + +// Search finds messages matching the given criteria. +// Returns messages from both inbox and archive. +func (m *Mailbox) Search(opts SearchOptions) ([]*Message, error) { + // Compile regex + re, err := regexp.Compile("(?i)" + opts.Query) // Case-insensitive + if err != nil { + return nil, fmt.Errorf("invalid search pattern: %w", err) + } + + var fromRe *regexp.Regexp + if opts.FromFilter != "" { + fromRe, err = regexp.Compile("(?i)" + opts.FromFilter) + if err != nil { + return nil, fmt.Errorf("invalid from pattern: %w", err) + } + } + + // Get inbox messages + inbox, err := m.List() + if err != nil { + return nil, err + } + + // Get archived messages + archived, err := m.ListArchived() + if err != nil && !os.IsNotExist(err) { + return nil, err + } + + // Combine and search + all := append(inbox, archived...) + var matches []*Message + + for _, msg := range all { + // Apply from filter + if fromRe != nil && !fromRe.MatchString(msg.From) { + continue + } + + // Search in specified fields + matched := false + if opts.SubjectOnly { + matched = re.MatchString(msg.Subject) + } else if opts.BodyOnly { + matched = re.MatchString(msg.Body) + } else { + // Search in both subject and body + matched = re.MatchString(msg.Subject) || re.MatchString(msg.Body) + } + + if matched { + matches = append(matches, msg) + } + } + + // Sort by timestamp (newest first) + sort.Slice(matches, func(i, j int) bool { + return matches[i].Timestamp.After(matches[j].Timestamp) + }) + + return matches, nil +} + // Count returns the total and unread message counts. func (m *Mailbox) Count() (total, unread int, err error) { messages, err := m.List()