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

210
cmd/accounts.go Normal file
View 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
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
}

299
cmd/pgp.go Normal file
View 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
View 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
}

View File

@@ -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
View 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
View 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
}