feat: Add mail mark, purge, search commands and batch archive (gt-d46)

- gt mail mark <id> --read/--unread: Change message read status
- gt mail delete <id> --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 <query>: Regex search with --from, --subject, --body, --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:
gastown/crew/jack
2026-01-01 20:31:29 -08:00
committed by Steve Yegge
parent 36d64eee14
commit 918fcc34fa
2 changed files with 680 additions and 13 deletions

View File

@@ -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 <message-id>",
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 <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.
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
}

View File

@@ -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()