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>
279 lines
7.0 KiB
Go
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, ", ")
|
|
}
|