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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user