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:
210
cmd/accounts.go
Normal file
210
cmd/accounts.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/frenocorp/pop/internal/accounts"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func accountsCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "accounts",
|
||||
Short: "Manage multiple ProtonMail accounts",
|
||||
Long: `Add, list, switch, and remove named ProtonMail account profiles.`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(accountsListCmd())
|
||||
cmd.AddCommand(accountsAddCmd())
|
||||
cmd.AddCommand(accountsRemoveCmd())
|
||||
cmd.AddCommand(accountsDefaultCmd())
|
||||
cmd.AddCommand(accountsShowCmd())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func accountsListCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all configured accounts",
|
||||
Long: `Show all saved ProtonMail account profiles.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
store, err := accounts.NewAccountsStore()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create accounts store: %w", err)
|
||||
}
|
||||
|
||||
accts, err := store.LoadAccounts()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load accounts: %w", err)
|
||||
}
|
||||
|
||||
if len(accts) == 0 {
|
||||
fmt.Println("No accounts configured. Use 'pop accounts add' to create one.")
|
||||
return nil
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "Name\tEmail\tDefault\tAPI Base\tCreated")
|
||||
fmt.Fprintln(w, "----\t-----\t-------\t--------\t-------")
|
||||
|
||||
for _, acc := range accts {
|
||||
def := "-"
|
||||
if acc.Default {
|
||||
def = "*"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
|
||||
acc.Name, acc.Email, def, acc.APIBaseURL, acc.CreatedAt)
|
||||
}
|
||||
|
||||
return w.Flush()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func accountsAddCmd() *cobra.Command {
|
||||
var name, email, apiURL string
|
||||
var isDefault bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "add <name>",
|
||||
Short: "Add a new account profile",
|
||||
Long: `Add a named ProtonMail account profile for multi-account support.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
accountName := name
|
||||
if len(args) > 0 && args[0] != "" {
|
||||
accountName = args[0]
|
||||
}
|
||||
if accountName == "" {
|
||||
return fmt.Errorf("account name is required")
|
||||
}
|
||||
if email == "" {
|
||||
return fmt.Errorf("email is required (--email)")
|
||||
}
|
||||
|
||||
store, err := accounts.NewAccountsStore()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create accounts store: %w", err)
|
||||
}
|
||||
|
||||
if err := store.AddAccount(accountName, email, apiURL, isDefault); err != nil {
|
||||
return fmt.Errorf("failed to add account: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Added account: %s (%s)\n", accountName, email)
|
||||
if isDefault {
|
||||
fmt.Println("Set as default account")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&name, "name", "", "Account name (or pass as positional argument)")
|
||||
cmd.Flags().StringVarP(&email, "email", "e", "", "ProtonMail email address")
|
||||
cmd.Flags().StringVar(&apiURL, "api-url", "", "Custom API base URL")
|
||||
cmd.Flags().BoolVarP(&isDefault, "default", "d", false, "Set as default account")
|
||||
_ = cmd.MarkFlagRequired("email")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func accountsRemoveCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "remove <name>",
|
||||
Short: "Remove an account profile",
|
||||
Long: `Remove a named ProtonMail account profile.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
accountName := args[0]
|
||||
|
||||
store, err := accounts.NewAccountsStore()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create accounts store: %w", err)
|
||||
}
|
||||
|
||||
if err := store.RemoveAccount(accountName); err != nil {
|
||||
return fmt.Errorf("failed to remove account: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Removed account: %s\n", accountName)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func accountsDefaultCmd() *cobra.Command {
|
||||
var setName string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "default [name]",
|
||||
Short: "Get or set the default account",
|
||||
Long: `Show the current default account, or set a new default.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
store, err := accounts.NewAccountsStore()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create accounts store: %w", err)
|
||||
}
|
||||
|
||||
if setName == "" && len(args) > 0 {
|
||||
setName = args[0]
|
||||
}
|
||||
|
||||
if setName != "" {
|
||||
if err := store.SetDefaultAccount(setName); err != nil {
|
||||
return fmt.Errorf("failed to set default account: %w", err)
|
||||
}
|
||||
fmt.Printf("Set default account to: %s\n", setName)
|
||||
return nil
|
||||
}
|
||||
|
||||
acct, err := store.GetAccount("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("no default account: %w", err)
|
||||
}
|
||||
fmt.Printf("Default account: %s (%s)\n", acct.Name, acct.Email)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&setName, "set", "", "Set default account to this name")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func accountsShowCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "show <name>",
|
||||
Short: "Show account details",
|
||||
Long: `Display details for a specific account profile.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
accountName := args[0]
|
||||
|
||||
store, err := accounts.NewAccountsStore()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create accounts store: %w", err)
|
||||
}
|
||||
|
||||
acct, err := store.GetAccount(accountName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get account: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Name: %s\n", acct.Name)
|
||||
fmt.Printf("Email: %s\n", acct.Email)
|
||||
fmt.Printf("UID: %s\n", acct.UID)
|
||||
fmt.Printf("API Base: %s\n", acct.APIBaseURL)
|
||||
fmt.Printf("Default: %t\n", acct.Default)
|
||||
fmt.Printf("Created: %s\n", acct.CreatedAt)
|
||||
if acct.LastUsedAt != "" {
|
||||
fmt.Printf("Last Used: %s\n", acct.LastUsedAt)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
365
cmd/bulk.go
Normal file
365
cmd/bulk.go
Normal 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
|
||||
}
|
||||
299
cmd/pgp.go
Normal file
299
cmd/pgp.go
Normal file
@@ -0,0 +1,299 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/frenocorp/pop/internal/pgp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func pgpCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "pgp",
|
||||
Short: "Manage PGP keys",
|
||||
Long: `Import, export, list, and manage PGP keys for ProtonMail encryption.`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(pgpListCmd())
|
||||
cmd.AddCommand(pgpImportCmd())
|
||||
cmd.AddCommand(pgpExportCmd())
|
||||
cmd.AddCommand(pgpRemoveCmd())
|
||||
cmd.AddCommand(pgpEncryptCmd())
|
||||
cmd.AddCommand(pgpDecryptCmd())
|
||||
cmd.AddCommand(pgpSignCmd())
|
||||
cmd.AddCommand(pgpVerifyCmd())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func pgpListCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all imported PGP keys",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
store, err := pgp.NewKeyStore()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create PGP store: %w", err)
|
||||
}
|
||||
|
||||
keys, err := store.ListKeys()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list keys: %w", err)
|
||||
}
|
||||
|
||||
if len(keys) == 0 {
|
||||
fmt.Println("No PGP keys imported.")
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
fmt.Printf("Key ID: %s\n Fingerprint: %s\n Emails: %v\n Trust: %s\n Encrypt: %t Sign: %t\n\n",
|
||||
key.KeyID, key.Fingerprint, key.Emails, key.TrustLevel, key.CanEncrypt, key.CanSign)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func pgpImportCmd() *cobra.Command {
|
||||
var trustLevel, filePath string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "import <file>",
|
||||
Short: "Import a PGP key from file",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
filePath = args[0]
|
||||
|
||||
store, err := pgp.NewKeyStore()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create PGP store: %w", err)
|
||||
}
|
||||
|
||||
key, err := store.ImportKeyFromFile(filePath, trustLevel)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to import key: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Imported key:\n ID: %s\n Fingerprint: %s\n Emails: %v\n",
|
||||
key.KeyID, key.Fingerprint, key.Emails)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&trustLevel, "trust", "unknown", "Trust level for the key")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func pgpExportCmd() *cobra.Command {
|
||||
var outputPath string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "export <key_id>",
|
||||
Short: "Export a PGP key",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
keyID := args[0]
|
||||
|
||||
store, err := pgp.NewKeyStore()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create PGP store: %w", err)
|
||||
}
|
||||
|
||||
if outputPath == "" {
|
||||
outputPath = keyID + ".asc"
|
||||
}
|
||||
|
||||
if err := store.ExportKey(keyID, outputPath); err != nil {
|
||||
return fmt.Errorf("failed to export key: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Exported key to: %s\n", outputPath)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&outputPath, "output", "o", "", "Output file path")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func pgpRemoveCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "remove <key_id>",
|
||||
Short: "Remove a PGP key",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
keyID := args[0]
|
||||
|
||||
store, err := pgp.NewKeyStore()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create PGP store: %w", err)
|
||||
}
|
||||
|
||||
if err := store.RemoveKey(keyID); err != nil {
|
||||
return fmt.Errorf("failed to remove key: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Removed key: %s\n", keyID)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func pgpEncryptCmd() *cobra.Command {
|
||||
var plaintext, keyID string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "encrypt <key_id> --plaintext \"text\"",
|
||||
Short: "Encrypt plaintext with a public key",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 0 {
|
||||
keyID = args[0]
|
||||
}
|
||||
|
||||
if plaintext == "" {
|
||||
return fmt.Errorf("plaintext is required (--plaintext)")
|
||||
}
|
||||
if keyID == "" {
|
||||
return fmt.Errorf("key ID is required (positional argument)")
|
||||
}
|
||||
|
||||
store, err := pgp.NewKeyStore()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create PGP store: %w", err)
|
||||
}
|
||||
|
||||
encrypted, err := store.EncryptData(keyID, plaintext)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(encrypted)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&plaintext, "plaintext", "p", "", "Plaintext to encrypt")
|
||||
cmd.MarkFlagRequired("plaintext")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func pgpDecryptCmd() *cobra.Command {
|
||||
var keyID, passphrase, encryptedData string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "decrypt <key_id>",
|
||||
Short: "Decrypt PGP-encrypted data",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 0 {
|
||||
keyID = args[0]
|
||||
}
|
||||
|
||||
store, err := pgp.NewKeyStore()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create PGP store: %w", err)
|
||||
}
|
||||
|
||||
decrypted, err := store.DecryptData(keyID, encryptedData, passphrase)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(decrypted)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&encryptedData, "encrypted", "e", "", "Encrypted data (armored)")
|
||||
cmd.Flags().StringVarP(&passphrase, "passphrase", "P", "", "Passphrase for private key")
|
||||
cmd.MarkFlagRequired("encrypted")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func pgpSignCmd() *cobra.Command {
|
||||
var plaintext, keyID, passphrase string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "sign <key_id> --plaintext \"text\"",
|
||||
Short: "Sign plaintext with a private key",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 0 {
|
||||
keyID = args[0]
|
||||
}
|
||||
|
||||
if plaintext == "" {
|
||||
return fmt.Errorf("plaintext is required (--plaintext)")
|
||||
}
|
||||
if keyID == "" {
|
||||
return fmt.Errorf("key ID is required (positional argument)")
|
||||
}
|
||||
|
||||
store, err := pgp.NewKeyStore()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create PGP store: %w", err)
|
||||
}
|
||||
|
||||
signature, err := store.SignData(keyID, plaintext, passphrase)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sign: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(signature)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&plaintext, "plaintext", "p", "", "Plaintext to sign")
|
||||
cmd.Flags().StringVarP(&passphrase, "passphrase", "P", "", "Passphrase for private key")
|
||||
cmd.MarkFlagRequired("plaintext")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func pgpVerifyCmd() *cobra.Command {
|
||||
var keyID, message, signature string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "verify <key_id>",
|
||||
Short: "Verify a detached signature",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 0 {
|
||||
keyID = args[0]
|
||||
}
|
||||
|
||||
store, err := pgp.NewKeyStore()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create PGP store: %w", err)
|
||||
}
|
||||
|
||||
verified, err := store.VerifySignature(keyID, message, signature)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verification failed: %w", err)
|
||||
}
|
||||
|
||||
if verified {
|
||||
fmt.Println("Signature is valid.")
|
||||
} else {
|
||||
fmt.Println("Signature is INVALID.")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&message, "message", "m", "", "Message to verify")
|
||||
cmd.Flags().StringVarP(&signature, "signature", "s", "", "Detached signature")
|
||||
cmd.MarkFlagRequired("message")
|
||||
cmd.MarkFlagRequired("signature")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func init() {
|
||||
_ = os.Getenv
|
||||
}
|
||||
111
cmd/plugin.go
Normal file
111
cmd/plugin.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/frenocorp/pop/internal/plugin"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func pluginCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "plugin",
|
||||
Short: "Manage plugins",
|
||||
Long: `List, enable, disable, and manage plugins for the Pop CLI.`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(pluginListCmd())
|
||||
cmd.AddCommand(pluginEnableCmd())
|
||||
cmd.AddCommand(pluginDisableCmd())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func pluginListCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all registered plugins",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
reg, err := plugin.NewPluginRegistry()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create plugin registry: %w", err)
|
||||
}
|
||||
|
||||
plugins, err := reg.ListPlugins()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list plugins: %w", err)
|
||||
}
|
||||
|
||||
if len(plugins) == 0 {
|
||||
fmt.Println("No plugins registered.")
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, p := range plugins {
|
||||
fmt.Printf("Name: %s\n Version: %s\n Description: %s\n Binary: %s\n\n",
|
||||
p.Name, p.Version, p.Description, p.Binary)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func pluginEnableCmd() *cobra.Command {
|
||||
var name string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "enable <name>",
|
||||
Short: "Enable a plugin",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
name = args[0]
|
||||
|
||||
reg, err := plugin.NewPluginRegistry()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create plugin registry: %w", err)
|
||||
}
|
||||
|
||||
if err := reg.EnablePlugin(name); err != nil {
|
||||
return fmt.Errorf("failed to enable plugin: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Enabled plugin: %s\n", name)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func pluginDisableCmd() *cobra.Command {
|
||||
var name string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "disable <name>",
|
||||
Short: "Disable a plugin",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
name = args[0]
|
||||
|
||||
reg, err := plugin.NewPluginRegistry()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create plugin registry: %w", err)
|
||||
}
|
||||
|
||||
if err := reg.DisablePlugin(name); err != nil {
|
||||
return fmt.Errorf("failed to disable plugin: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Disabled plugin: %s\n", name)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func init() {
|
||||
_ = os.Getenv
|
||||
}
|
||||
@@ -34,6 +34,10 @@ with full PGP encryption support.`,
|
||||
cmd.AddCommand(attachmentCmd())
|
||||
cmd.AddCommand(folderCmd())
|
||||
cmd.AddCommand(labelCmd())
|
||||
cmd.AddCommand(accountsCmd())
|
||||
cmd.AddCommand(webhookCmd())
|
||||
cmd.AddCommand(pgpCmd())
|
||||
cmd.AddCommand(pluginCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -47,6 +51,10 @@ func NewRootCmd() *cobra.Command {
|
||||
rootCmd.AddCommand(attachmentCmd())
|
||||
rootCmd.AddCommand(folderCmd())
|
||||
rootCmd.AddCommand(labelCmd())
|
||||
rootCmd.AddCommand(accountsCmd())
|
||||
rootCmd.AddCommand(webhookCmd())
|
||||
rootCmd.AddCommand(pgpCmd())
|
||||
rootCmd.AddCommand(pluginCmd())
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
|
||||
278
cmd/thread.go
Normal file
278
cmd/thread.go
Normal file
@@ -0,0 +1,278 @@
|
||||
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, ", ")
|
||||
}
|
||||
162
cmd/webhook.go
Normal file
162
cmd/webhook.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/frenocorp/pop/internal/webhook"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func webhookCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "webhook",
|
||||
Short: "Manage webhooks for ProtonMail",
|
||||
Long: `Add, list, verify, and remove webhooks for real-time ProtonMail notifications.`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(webhookListCmd())
|
||||
cmd.AddCommand(webhookAddCmd())
|
||||
cmd.AddCommand(webhookVerifyCmd())
|
||||
cmd.AddCommand(webhookRemoveCmd())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func webhookListCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all configured webhooks",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
store, err := webhook.NewWebhookStore()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create webhook store: %w", err)
|
||||
}
|
||||
|
||||
webhooks, err := store.ListWebhooks()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list webhooks: %w", err)
|
||||
}
|
||||
|
||||
if len(webhooks) == 0 {
|
||||
fmt.Println("No webhooks configured.")
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, wh := range webhooks {
|
||||
fmt.Printf("Name: %s\n URL: %s\n ID: %s\n Events: %v\n Active: %t\n Created: %s\n\n",
|
||||
wh.Name, wh.URL, wh.ID, wh.Events, wh.Active, wh.CreatedAt)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func webhookAddCmd() *cobra.Command {
|
||||
var name, url string
|
||||
var events []string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "add <name>",
|
||||
Short: "Add a webhook endpoint",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
name = args[0]
|
||||
|
||||
if url == "" {
|
||||
return fmt.Errorf("webhook URL is required (--url)")
|
||||
}
|
||||
if len(events) == 0 {
|
||||
return fmt.Errorf("at least one event type is required (--events)")
|
||||
}
|
||||
|
||||
eventTypes := make([]webhook.EventType, len(events))
|
||||
for i, e := range events {
|
||||
eventTypes[i] = webhook.EventType(e)
|
||||
}
|
||||
|
||||
store, err := webhook.NewWebhookStore()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create webhook store: %w", err)
|
||||
}
|
||||
|
||||
wh, err := store.AddWebhook(name, url, eventTypes, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add webhook: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Webhook added:\n Name: %s\n URL: %s\n ID: %s\n Events: %v\n", wh.Name, wh.URL, wh.ID, wh.Events)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&url, "url", "", "Webhook URL")
|
||||
cmd.Flags().StringArrayVar(&events, "events", []string{"mail.received"}, "Event types (comma-separated, e.g. mail.received,mail.sent)")
|
||||
cmd.MarkFlagRequired("url")
|
||||
cmd.MarkFlagRequired("events")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func webhookVerifyCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "verify",
|
||||
Short: "Verify webhook signatures",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
store, err := webhook.NewWebhookStore()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create webhook store: %w", err)
|
||||
}
|
||||
|
||||
webhooks, err := store.ListWebhooks()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list webhooks: %w", err)
|
||||
}
|
||||
|
||||
if len(webhooks) == 0 {
|
||||
fmt.Println("No webhooks to verify.")
|
||||
return nil
|
||||
}
|
||||
|
||||
testPayload := []byte(`{"test": true}`)
|
||||
for _, wh := range webhooks {
|
||||
valid := webhook.VerifySignature(wh.Secret, testPayload, webhook.ComputeSignature(wh.Secret, testPayload))
|
||||
if valid {
|
||||
fmt.Printf("Webhook %s (%s): signature OK\n", wh.ID, wh.URL)
|
||||
} else {
|
||||
fmt.Printf("Webhook %s (%s): signature FAILED\n", wh.ID, wh.URL)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func webhookRemoveCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "remove <id>",
|
||||
Short: "Remove a webhook",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id := args[0]
|
||||
|
||||
store, err := webhook.NewWebhookStore()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create webhook store: %w", err)
|
||||
}
|
||||
|
||||
if err := store.RemoveWebhook(id); err != nil {
|
||||
return fmt.Errorf("failed to remove webhook: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Removed webhook: %s\n", id)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
_ = os.Getenv
|
||||
}
|
||||
Reference in New Issue
Block a user