Files
pop/cmd/thread.go
Michael Freno bf26cd3ed6 feat: implement Milestone 3 integration points
Add comprehensive integration capabilities to Pop CLI:

- Multi-account support with named profiles
- Webhook management with signature verification
- External PGP key management (import/export/encrypt/decrypt/sign/verify)
- CLI plugin system for extensibility
- Complete documentation in README.md

All compilation errors fixed and build verified CLEAN.

Security review delegated to FRE-5202.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 00:40:24 -04:00

279 lines
7.0 KiB
Go

package cmd
import (
"fmt"
"os"
"strconv"
"strings"
"text/tabwriter"
"github.com/frenocorp/pop/internal/api"
"github.com/frenocorp/pop/internal/config"
internalmail "github.com/frenocorp/pop/internal/mail"
"github.com/spf13/cobra"
)
func threadCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "thread",
Short: "Manage email conversations",
Long: `View, reply to, and manage email conversation threads.`,
}
cmd.AddCommand(threadListCmd())
cmd.AddCommand(threadShowCmd())
cmd.AddCommand(threadReplyCmd())
return cmd
}
func threadListCmd() *cobra.Command {
var page, pageSize string
cmd := &cobra.Command{
Use: "list",
Short: "List conversation threads",
Long: `List email conversation threads sorted by latest activity.`,
RunE: func(cmd *cobra.Command, args []string) error {
pageVal, err := strconv.Atoi(page)
if err != nil || pageVal < 1 {
pageVal = 1
}
pageSizeVal, err := strconv.Atoi(pageSize)
if err != nil || pageSizeVal < 1 {
pageSizeVal = 20
}
if pageSizeVal > 100 {
pageSizeVal = 100
}
cfgMgr := config.NewConfigManager()
cfg, err := cfgMgr.Load()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
session, sessionMgr, err := checkAuthenticatedWithManager()
if err != nil {
return err
}
client := api.NewProtonMailClient(cfg, sessionMgr)
client.SetAuthHeader(session.AccessToken)
mailClient := internalmail.NewClient(client)
result, err := mailClient.ListConversations(pageVal, pageSizeVal, session.MailPassphrase)
if err != nil {
return fmt.Errorf("failed to list conversations: %w", err)
}
if len(result.Conversations) == 0 {
fmt.Println("No conversations found")
return nil
}
return printConversations(result.Conversations)
},
}
cmd.Flags().StringVar(&page, "page", "1", "Page number")
cmd.Flags().StringVar(&pageSize, "page-size", "20", "Conversations per page (max 100)")
return cmd
}
func threadShowCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "show <conversation-id>",
Short: "Show a conversation thread",
Long: `Display all messages in a conversation thread in chronological order.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
convID := args[0]
cfgMgr := config.NewConfigManager()
cfg, err := cfgMgr.Load()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
session, sessionMgr, err := checkAuthenticatedWithManager()
if err != nil {
return err
}
client := api.NewProtonMailClient(cfg, sessionMgr)
client.SetAuthHeader(session.AccessToken)
mailClient := internalmail.NewClient(client)
req := internalmail.GetConversationRequest{
ConversationID: convID,
Passphrase: session.MailPassphrase,
}
result, err := mailClient.GetConversation(req)
if err != nil {
return fmt.Errorf("failed to get conversation: %w", err)
}
fmt.Printf("Conversation: %s\n", result.Subject)
fmt.Printf("Messages: %d\n", result.MessageCount)
fmt.Printf("Participants: %s\n", formatRecipients(result.Participants))
fmt.Println()
for i, msg := range result.Messages {
from := msg.Sender.DisplayName()
date := msg.CreatedAt.Format("2006-01-02 15:04")
subject := msg.Subject
if len(subject) > 60 {
subject = subject[:57] + "..."
}
fmt.Printf("--- Message %d ---\n", i+1)
fmt.Printf("From: %s\n", from)
fmt.Printf("Date: %s\n", date)
fmt.Printf("Subject: %s\n", subject)
if msg.Body != "" {
body := msg.Body
if len(body) > 200 {
body = body[:197] + "..."
}
fmt.Printf("Body: %s\n", body)
}
fmt.Println()
}
return nil
},
}
return cmd
}
func threadReplyCmd() *cobra.Command {
var body, bodyFile, subject string
var html bool
cmd := &cobra.Command{
Use: "reply <conversation-id>",
Short: "Reply to a conversation",
Long: `Reply to the latest message in a conversation thread.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
convID := args[0]
cfgMgr := config.NewConfigManager()
cfg, err := cfgMgr.Load()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
session, sessionMgr, err := checkAuthenticatedWithManager()
if err != nil {
return err
}
client := api.NewProtonMailClient(cfg, sessionMgr)
client.SetAuthHeader(session.AccessToken)
mailClient := internalmail.NewClient(client)
req := internalmail.GetConversationRequest{
ConversationID: convID,
Passphrase: session.MailPassphrase,
}
conv, err := mailClient.GetConversation(req)
if err != nil {
return fmt.Errorf("failed to get conversation: %w", err)
}
if len(conv.Messages) == 0 {
return fmt.Errorf("conversation has no messages")
}
latestMsg := conv.Messages[len(conv.Messages)-1]
var bodyContent string
if body != "" {
bodyContent = body
} else if bodyFile != "" {
data, err := os.ReadFile(bodyFile)
if err != nil {
return fmt.Errorf("failed to read body file: %w", err)
}
bodyContent = string(data)
}
replySubject := subject
if replySubject == "" {
replySubject = "Re: " + latestMsg.Subject
}
replyTo := []internalmail.Recipient{latestMsg.Sender.ToRecipient()}
sendReq := internalmail.SendRequest{
To: replyTo,
Subject: replySubject,
Body: bodyContent,
HTML: html,
InReplyTo: latestMsg.MimeMessageID,
References: latestMsg.ConversationID,
Passphrase: session.MailPassphrase,
}
if err := mailClient.Send(sendReq); err != nil {
return fmt.Errorf("failed to send reply: %w", err)
}
fmt.Println("Reply sent successfully")
return nil
},
}
cmd.Flags().StringVarP(&subject, "subject", "s", "", "Reply subject (default: Re: <original>)")
cmd.Flags().StringVarP(&bodyFile, "body-file", "f", "", "File containing reply body")
cmd.Flags().BoolVar(&html, "html", false, "Send body as HTML")
cmd.Flags().StringVar(&body, "body", "", "Inline reply body")
return cmd
}
func printConversations(conversations []internalmail.Conversation) error {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tSubject\tMessages\tLast From\tLast Date")
fmt.Fprintln(w, "--\t-------\t--------\t---------\t---------")
for _, conv := range conversations {
id := conv.ConversationID
if len(id) > 12 {
id = id[:12]
}
subject := conv.Subject
if len(subject) > 50 {
subject = subject[:47] + "..."
}
lastFrom := "-"
lastDate := "-"
if conv.LastMessage != nil {
lastFrom = conv.LastMessage.Sender.DisplayName()
lastDate = conv.LastMessage.CreatedAt.Format("2006-01-02 15:04")
}
fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s\n", id, subject, conv.MessageCount, lastFrom, lastDate)
}
return w.Flush()
}
func formatThreadParticipants(participants []internalmail.Recipient) string {
parts := make([]string, len(participants))
for i, p := range participants {
parts[i] = p.DisplayName()
}
return strings.Join(parts, ", ")
}