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>
This commit is contained in:
2026-05-12 17:31:58 -04:00
parent e7e77fcc20
commit bf26cd3ed6
16 changed files with 3566 additions and 85 deletions

365
cmd/bulk.go Normal file
View File

@@ -0,0 +1,365 @@
package cmd
import (
"fmt"
"os"
"strings"
"github.com/frenocorp/pop/internal/api"
"github.com/frenocorp/pop/internal/config"
internalmail "github.com/frenocorp/pop/internal/mail"
"github.com/spf13/cobra"
)
func bulkCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "bulk",
Short: "Bulk operations on messages",
Long: `Perform operations on multiple messages at once: delete, trash, star, mark read.`,
}
cmd.AddCommand(bulkDeleteCmd())
cmd.AddCommand(bulkTrashCmd())
cmd.AddCommand(bulkStarCmd())
cmd.AddCommand(bulkUnstarCmd())
cmd.AddCommand(bulkMarkReadCmd())
cmd.AddCommand(bulkMarkUnreadCmd())
return cmd
}
func bulkDeleteCmd() *cobra.Command {
var ids, idsFile string
cmd := &cobra.Command{
Use: "delete",
Short: "Permanently delete multiple messages",
Long: `Permanently delete multiple messages from ProtonMail. Use --ids for comma-separated IDs or --ids-file for a file with one ID per line.`,
RunE: func(cmd *cobra.Command, args []string) error {
messageIDs := collectMessageIDs(ids, idsFile)
if len(messageIDs) == 0 {
return fmt.Errorf("no message IDs provided")
}
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.BulkDelete(messageIDs)
if err != nil {
return fmt.Errorf("failed to bulk delete: %w", err)
}
fmt.Printf("Deleted %d/%d messages\n", result.SuccessCount, result.Total)
if len(result.Errors) > 0 {
fmt.Fprintln(os.Stderr, "Errors:")
for _, e := range result.Errors {
fmt.Fprintf(os.Stderr, " - %s: %s\n", e.MessageID, e.Error)
}
}
return nil
},
}
cmd.Flags().StringVarP(&ids, "ids", "i", "", "Comma-separated message IDs")
cmd.Flags().StringVarP(&idsFile, "ids-file", "f", "", "File with one message ID per line")
return cmd
}
func bulkTrashCmd() *cobra.Command {
var ids, idsFile string
cmd := &cobra.Command{
Use: "trash",
Short: "Move multiple messages to trash",
Long: `Move multiple messages to the trash folder. Use --ids for comma-separated IDs or --ids-file for a file with one ID per line.`,
RunE: func(cmd *cobra.Command, args []string) error {
messageIDs := collectMessageIDs(ids, idsFile)
if len(messageIDs) == 0 {
return fmt.Errorf("no message IDs provided")
}
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.BulkTrash(messageIDs, session.MailPassphrase)
if err != nil {
return fmt.Errorf("failed to bulk trash: %w", err)
}
fmt.Printf("Trashed %d/%d messages\n", result.SuccessCount, result.Total)
if len(result.Errors) > 0 {
fmt.Fprintln(os.Stderr, "Errors:")
for _, e := range result.Errors {
fmt.Fprintf(os.Stderr, " - %s: %s\n", e.MessageID, e.Error)
}
}
return nil
},
}
cmd.Flags().StringVarP(&ids, "ids", "i", "", "Comma-separated message IDs")
cmd.Flags().StringVarP(&idsFile, "ids-file", "f", "", "File with one message ID per line")
return cmd
}
func bulkStarCmd() *cobra.Command {
var ids, idsFile string
cmd := &cobra.Command{
Use: "star",
Short: "Star multiple messages",
Long: `Star multiple messages at once. Use --ids for comma-separated IDs or --ids-file for a file with one ID per line.`,
RunE: func(cmd *cobra.Command, args []string) error {
messageIDs := collectMessageIDs(ids, idsFile)
if len(messageIDs) == 0 {
return fmt.Errorf("no message IDs provided")
}
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.BulkStar(messageIDs, true)
if err != nil {
return fmt.Errorf("failed to bulk star: %w", err)
}
fmt.Printf("Starred %d/%d messages\n", result.SuccessCount, result.Total)
if len(result.Errors) > 0 {
fmt.Fprintln(os.Stderr, "Errors:")
for _, e := range result.Errors {
fmt.Fprintf(os.Stderr, " - %s: %s\n", e.MessageID, e.Error)
}
}
return nil
},
}
cmd.Flags().StringVarP(&ids, "ids", "i", "", "Comma-separated message IDs")
cmd.Flags().StringVarP(&idsFile, "ids-file", "f", "", "File with one message ID per line")
return cmd
}
func bulkUnstarCmd() *cobra.Command {
var ids, idsFile string
cmd := &cobra.Command{
Use: "unstar",
Short: "Unstar multiple messages",
Long: `Unstar multiple messages at once. Use --ids for comma-separated IDs or --ids-file for a file with one ID per line.`,
RunE: func(cmd *cobra.Command, args []string) error {
messageIDs := collectMessageIDs(ids, idsFile)
if len(messageIDs) == 0 {
return fmt.Errorf("no message IDs provided")
}
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.BulkStar(messageIDs, false)
if err != nil {
return fmt.Errorf("failed to bulk unstar: %w", err)
}
fmt.Printf("Unstarred %d/%d messages\n", result.SuccessCount, result.Total)
if len(result.Errors) > 0 {
fmt.Fprintln(os.Stderr, "Errors:")
for _, e := range result.Errors {
fmt.Fprintf(os.Stderr, " - %s: %s\n", e.MessageID, e.Error)
}
}
return nil
},
}
cmd.Flags().StringVarP(&ids, "ids", "i", "", "Comma-separated message IDs")
cmd.Flags().StringVarP(&idsFile, "ids-file", "f", "", "File with one message ID per line")
return cmd
}
func bulkMarkReadCmd() *cobra.Command {
var ids, idsFile string
cmd := &cobra.Command{
Use: "mark-read",
Short: "Mark multiple messages as read",
Long: `Mark multiple messages as read at once. Use --ids for comma-separated IDs or --ids-file for a file with one ID per line.`,
RunE: func(cmd *cobra.Command, args []string) error {
messageIDs := collectMessageIDs(ids, idsFile)
if len(messageIDs) == 0 {
return fmt.Errorf("no message IDs provided")
}
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.BulkMarkRead(messageIDs, true)
if err != nil {
return fmt.Errorf("failed to bulk mark read: %w", err)
}
fmt.Printf("Marked %d/%d messages as read\n", result.SuccessCount, result.Total)
if len(result.Errors) > 0 {
fmt.Fprintln(os.Stderr, "Errors:")
for _, e := range result.Errors {
fmt.Fprintf(os.Stderr, " - %s: %s\n", e.MessageID, e.Error)
}
}
return nil
},
}
cmd.Flags().StringVarP(&ids, "ids", "i", "", "Comma-separated message IDs")
cmd.Flags().StringVarP(&idsFile, "ids-file", "f", "", "File with one message ID per line")
return cmd
}
func bulkMarkUnreadCmd() *cobra.Command {
var ids, idsFile string
cmd := &cobra.Command{
Use: "mark-unread",
Short: "Mark multiple messages as unread",
Long: `Mark multiple messages as unread at once. Use --ids for comma-separated IDs or --ids-file for a file with one ID per line.`,
RunE: func(cmd *cobra.Command, args []string) error {
messageIDs := collectMessageIDs(ids, idsFile)
if len(messageIDs) == 0 {
return fmt.Errorf("no message IDs provided")
}
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.BulkMarkRead(messageIDs, false)
if err != nil {
return fmt.Errorf("failed to bulk mark unread: %w", err)
}
fmt.Printf("Marked %d/%d messages as unread\n", result.SuccessCount, result.Total)
if len(result.Errors) > 0 {
fmt.Fprintln(os.Stderr, "Errors:")
for _, e := range result.Errors {
fmt.Fprintf(os.Stderr, " - %s: %s\n", e.MessageID, e.Error)
}
}
return nil
},
}
cmd.Flags().StringVarP(&ids, "ids", "i", "", "Comma-separated message IDs")
cmd.Flags().StringVarP(&idsFile, "ids-file", "f", "", "File with one message ID per line")
return cmd
}
func collectMessageIDs(ids, idsFile string) []string {
var messageIDs []string
if idsFile != "" {
data, err := os.ReadFile(idsFile)
if err != nil {
return messageIDs
}
lines := strings.Split(string(data), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
messageIDs = append(messageIDs, line)
}
}
if ids != "" {
for _, id := range strings.Split(ids, ",") {
id = strings.TrimSpace(id)
if id != "" {
messageIDs = append(messageIDs, id)
}
}
}
return messageIDs
}