From bf26cd3ed6eeba6ec185e8c63eb9553e8f16c5bf Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Tue, 12 May 2026 17:31:58 -0400 Subject: [PATCH] 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 --- README.md | 192 +++++++++- cmd/accounts.go | 210 +++++++++++ cmd/bulk.go | 365 +++++++++++++++++++ cmd/pgp.go | 299 ++++++++++++++++ cmd/plugin.go | 111 ++++++ cmd/root.go | 8 + cmd/thread.go | 278 +++++++++++++++ cmd/webhook.go | 162 +++++++++ internal/accounts/accounts.go | 235 +++++++++++++ internal/mail/FRE-4762-verification.md | 37 ++ internal/mail/client.go | 334 ++++++++++++++++++ internal/mail/client_test.go | 149 ++++---- internal/mail/types.go | 140 +++++++- internal/pgp/pgp.go | 465 +++++++++++++++++++++++++ internal/plugin/plugin.go | 291 ++++++++++++++++ internal/webhook/webhook.go | 375 ++++++++++++++++++++ 16 files changed, 3566 insertions(+), 85 deletions(-) create mode 100644 cmd/accounts.go create mode 100644 cmd/bulk.go create mode 100644 cmd/pgp.go create mode 100644 cmd/plugin.go create mode 100644 cmd/thread.go create mode 100644 cmd/webhook.go create mode 100644 internal/accounts/accounts.go create mode 100644 internal/mail/FRE-4762-verification.md create mode 100644 internal/pgp/pgp.go create mode 100644 internal/plugin/plugin.go create mode 100644 internal/webhook/webhook.go diff --git a/README.md b/README.md index 7476822..2c7d51a 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ A ProtonMail CLI tool written in Go, similar to gog. - **Session Management**: Secure token storage in `~/.config/pop/` - **ProtonMail API Client**: REST client with rate limiting and error handling - **PGP Encryption**: Full support for ProtonMail's PGP encryption via gopenpgp v2 +- **Multi-Account Support**: Named account profiles for managing multiple ProtonMail accounts +- **Webhook Management**: Real-time notifications via webhook subscriptions +- **External PGP Key Management**: Import, export, encrypt, decrypt, sign, and verify with external PGP keys +- **CLI Plugin System**: Extend Pop functionality with external plugins ## Installation @@ -23,6 +27,8 @@ make install ## Usage +### Authentication + ```bash # Initialize login (interactive mode with masked password prompt) pop login @@ -34,20 +40,126 @@ pop session pop logout ``` +### Multi-Account Support + +```bash +# List all accounts +pop accounts list + +# Add a new account +pop accounts add work --email work@example.com --default + +# Add with custom API URL +pop accounts add personal --email personal@protonmail.ch --api-url https://api.protonmail.ch + +# Switch default account +pop accounts default work + +# View account details +pop accounts show work + +# Remove an account +pop accounts remove old-account +``` + +### Webhook Management + +```bash +# List all webhooks +pop webhook list + +# Add a webhook +pop webhook add notifications --url https://example.com/webhook --events mail.received,mail.sent + +# Verify webhook signatures +pop webhook verify + +# Remove a webhook +pop webhook remove wh_1234567890 +``` + +### PGP Key Management + +```bash +# List all imported keys +pop pgp list + +# Import a key from file +pop pgp import key.asc --trust full + +# Export a key +pop pgp export ABCD1234 --output mykey.asc + +# Encrypt data +pop pgp encrypt ABCD1234 --plaintext "Secret message" + +# Decrypt data +pop pgp decrypt ABCD1234 --encrypted "-----BEGIN PGP MESSAGE-----..." + +# Sign data +pop pgp sign ABCD1234 --plaintext "Message to sign" --passphrase "my passphrase" + +# Verify a signature +pop pgp verify ABCD1234 --message "Original message" --signature "-----BEGIN PGP SIGNATURE-----..." + +# Remove a key +pop pgp remove ABCD1234 +``` + +### Plugin Management + +```bash +# List all plugins +pop plugin list + +# Enable a plugin +pop plugin enable myplugin + +# Disable a plugin +pop plugin disable myplugin +``` + ## Project Structure ``` pop/ ├── cmd/ │ ├── root.go # CLI root command -│ └── auth.go # Authentication commands +│ ├── auth.go # Authentication commands +│ ├── mail.go # Email management +│ ├── draft.go # Draft management +│ ├── contact.go # Contact management +│ ├── attachment.go # Attachment handling +│ ├── folder.go # Folder management +│ ├── label.go # Label management +│ ├── accounts.go # Multi-account support +│ ├── webhook.go # Webhook management +│ ├── pgp.go # PGP key management +│ ├── plugin.go # Plugin management +│ └── thread.go # Thread management ├── internal/ │ ├── auth/ # Session management │ │ └── session.go │ ├── config/ # Configuration handling │ │ └── config.go -│ └── api/ # ProtonMail API client -│ └── client.go +│ ├── api/ # ProtonMail API client +│ │ └── client.go +│ ├── mail/ # Mail-related functionality +│ │ └── mail.go +│ ├── contact/ # Contact management +│ │ └── contact.go +│ ├── attachment/ # Attachment handling +│ │ └── attachment.go +│ ├── labels/ # Label management +│ │ └── labels.go +│ ├── accounts/ # Multi-account support +│ │ └── accounts.go +│ ├── webhook/ # Webhook management +│ │ └── webhook.go +│ ├── pgp/ # PGP key management +│ │ └── pgp.go +│ └── plugin/ # Plugin system +│ └── plugin.go ├── .github/ │ └── workflows/ │ └── ci.yml # CI/CD pipeline @@ -83,6 +195,80 @@ Session data is stored in `~/.config/pop/session.json`: } ``` +### Multi-Account Configuration + +Accounts are stored in `~/.config/pop/accounts.json`: + +```json +[ + { + "name": "work", + "email": "work@example.com", + "api_base_url": "https://api.protonmail.ch", + "default": true, + "created_at": "2024-01-01T00:00:00Z" + } +] +``` + +### Webhook Configuration + +Webhooks are stored in `~/.config/pop/webhooks.json`: + +```json +[ + { + "id": "wh_1234567890", + "name": "notifications", + "url": "https://example.com/webhook", + "events": ["mail.received", "mail.sent"], + "secret": "abc123...", + "active": true, + "created_at": "2024-01-01T00:00:00Z" + } +] +``` + +### PGP Key Configuration + +PGP keys are stored in `~/.config/pop/pgp_keys.json` with key files in `~/.config/pop/pgp_keys/`: + +```json +[ + { + "key_id": "ABCD1234", + "fingerprint": "ABCD1234567890ABCD1234567890ABCD1234", + "emails": ["user@example.com"], + "trust_level": "full", + "can_encrypt": true, + "can_sign": true, + "armor_file": "/home/user/.config/pop/pgp_keys/ABCD1234.asc" + } +] +``` + +### Plugin Configuration + +Plugins are stored in `~/.config/pop/plugins.json` with binaries in `~/.config/pop/plugins/`: + +```json +[ + { + "name": "myplugin", + "version": "1.0.0", + "description": "My custom plugin", + "binary": "/home/user/.config/pop/plugins/pop-myplugin", + "enabled": true, + "commands": [ + { + "name": "mycommand", + "description": "My custom command" + } + ] + } +] +``` + ## Development ```bash diff --git a/cmd/accounts.go b/cmd/accounts.go new file mode 100644 index 0000000..535ca3a --- /dev/null +++ b/cmd/accounts.go @@ -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 ", + 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 ", + 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 ", + 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 + }, + } +} diff --git a/cmd/bulk.go b/cmd/bulk.go new file mode 100644 index 0000000..7fe3f27 --- /dev/null +++ b/cmd/bulk.go @@ -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 +} diff --git a/cmd/pgp.go b/cmd/pgp.go new file mode 100644 index 0000000..6d64e31 --- /dev/null +++ b/cmd/pgp.go @@ -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 ", + 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 ", + 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 ", + 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 --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 ", + 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 --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 ", + 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 +} diff --git a/cmd/plugin.go b/cmd/plugin.go new file mode 100644 index 0000000..d78a4bb --- /dev/null +++ b/cmd/plugin.go @@ -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 ", + 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 ", + 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 +} diff --git a/cmd/root.go b/cmd/root.go index 2bbafbb..eb3c1da 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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 } diff --git a/cmd/thread.go b/cmd/thread.go new file mode 100644 index 0000000..a551d9d --- /dev/null +++ b/cmd/thread.go @@ -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 ", + 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 ", + 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: )") + 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, ", ") +} diff --git a/cmd/webhook.go b/cmd/webhook.go new file mode 100644 index 0000000..206e9c8 --- /dev/null +++ b/cmd/webhook.go @@ -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 ", + 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 ", + 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 +} diff --git a/internal/accounts/accounts.go b/internal/accounts/accounts.go new file mode 100644 index 0000000..a12ed2e --- /dev/null +++ b/internal/accounts/accounts.go @@ -0,0 +1,235 @@ +package accounts + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/frenocorp/pop/internal/config" +) + +// Account represents a named ProtonMail account profile. +type Account struct { + Name string `json:"name"` + Email string `json:"email"` + UID string `json:"uid,omitempty"` + APIBaseURL string `json:"api_base_url"` + Default bool `json:"default"` + CreatedAt string `json:"created_at"` + LastUsedAt string `json:"last_used_at,omitempty"` +} + +// AccountsStore manages multiple named account profiles. +type AccountsStore struct { + configDir string + accountsFile string +} + +// NewAccountsStore creates a new store for managing multiple accounts. +func NewAccountsStore() (*AccountsStore, error) { + cfg := config.NewConfigManager() + configDir := cfg.ConfigDir() + + return &AccountsStore{ + configDir: configDir, + accountsFile: filepath.Join(configDir, "accounts.json"), + }, nil +} + +// LoadAccounts reads all stored accounts from disk. +func (s *AccountsStore) LoadAccounts() ([]Account, error) { + if err := os.MkdirAll(s.configDir, 0700); err != nil { + return nil, fmt.Errorf("failed to create config dir: %w", err) + } + + data, err := os.ReadFile(s.accountsFile) + if err != nil { + if os.IsNotExist(err) { + return []Account{}, nil + } + return nil, fmt.Errorf("failed to read accounts file: %w", err) + } + + var accounts []Account + if err := json.Unmarshal(data, &accounts); err != nil { + return nil, fmt.Errorf("failed to parse accounts: %w", err) + } + + return accounts, nil +} + +// SaveAccounts writes all accounts to disk. +func (s *AccountsStore) SaveAccounts(accounts []Account) error { + if err := os.MkdirAll(s.configDir, 0700); err != nil { + return fmt.Errorf("failed to create config dir: %w", err) + } + + data, err := json.MarshalIndent(accounts, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal accounts: %w", err) + } + + if err := os.WriteFile(s.accountsFile, data, 0600); err != nil { + return fmt.Errorf("failed to write accounts file: %w", err) + } + + return nil +} + +// AddAccount adds a new account profile. If name conflicts, returns an error. +func (s *AccountsStore) AddAccount(name, email, apiBaseURL string, isDefault bool) error { + accounts, err := s.LoadAccounts() + if err != nil { + return err + } + + for _, acc := range accounts { + if acc.Name == name { + return fmt.Errorf("account with name %q already exists", name) + } + } + + if apiBaseURL == "" { + apiBaseURL = "https://api.protonmail.ch" + } + + account := Account{ + Name: name, + Email: email, + APIBaseURL: apiBaseURL, + Default: isDefault, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + } + + if isDefault { + for i := range accounts { + accounts[i].Default = false + } + } + + accounts = append(accounts, account) + return s.SaveAccounts(accounts) +} + +// GetAccount retrieves an account by name. Falls back to default if name is empty. +func (s *AccountsStore) GetAccount(name string) (*Account, error) { + accounts, err := s.LoadAccounts() + if err != nil { + return nil, err + } + + if name != "" { + for _, acc := range accounts { + if acc.Name == name { + return &acc, nil + } + } + return nil, fmt.Errorf("account %q not found", name) + } + + for _, acc := range accounts { + if acc.Default { + return &acc, nil + } + } + + return nil, fmt.Errorf("no default account set (use 'pop accounts add' to create one)") +} + +// UpdateAccount updates an existing account's fields. +func (s *AccountsStore) UpdateAccount(name string, email, apiBaseURL *string, isDefault *bool) (*Account, error) { + accounts, err := s.LoadAccounts() + if err != nil { + return nil, err + } + + found := false + for i, acc := range accounts { + if acc.Name == name { + if email != nil { + accounts[i].Email = *email + } + if apiBaseURL != nil { + accounts[i].APIBaseURL = *apiBaseURL + } + if isDefault != nil && *isDefault { + for j := range accounts { + accounts[j].Default = false + } + accounts[i].Default = true + } + found = true + accounts[i].LastUsedAt = time.Now().UTC().Format(time.RFC3339) + + if err := s.SaveAccounts(accounts); err != nil { + return nil, err + } + return &accounts[i], nil + } + } + + if !found { + return nil, fmt.Errorf("account %q not found", name) + } + return nil, nil +} + +// SetDefaultAccount sets the default account by name. +func (s *AccountsStore) SetDefaultAccount(name string) error { + accounts, err := s.LoadAccounts() + if err != nil { + return err + } + + found := false + for i, acc := range accounts { + if acc.Name == name { + accounts[i].Default = true + found = true + } else { + accounts[i].Default = false + } + } + + if !found { + return fmt.Errorf("account %q not found", name) + } + + return s.SaveAccounts(accounts) +} + +// RemoveAccount removes an account by name. +func (s *AccountsStore) RemoveAccount(name string) error { + accounts, err := s.LoadAccounts() + if err != nil { + return err + } + + newAccounts := make([]Account, 0, len(accounts)) + found := false + for _, acc := range accounts { + if acc.Name == name { + found = true + continue + } + newAccounts = append(newAccounts, acc) + } + + if !found { + return fmt.Errorf("account %q not found", name) + } + + return s.SaveAccounts(newAccounts) +} + +// AccountSessionDir returns the session directory for a given account name. +func AccountSessionDir(configDir, accountName string) string { + return filepath.Join(configDir, "accounts", accountName) +} + +// AccountSessionFile returns the session file path for a given account. +func AccountSessionFile(configDir, accountName string) string { + return filepath.Join(AccountSessionDir(configDir, accountName), "session.json") +} diff --git a/internal/mail/FRE-4762-verification.md b/internal/mail/FRE-4762-verification.md new file mode 100644 index 0000000..c22eada --- /dev/null +++ b/internal/mail/FRE-4762-verification.md @@ -0,0 +1,37 @@ +# FRE-4762 Verification Complete + +**Issue:** FRE-4762 — Fix API endpoint paths and HTTP methods to match ProtonMail contract + +**Status:** ✅ **DONE** + +## Verification Summary + +### Review Completed By +- **Reviewer:** Code Reviewer (f274248f-c47e-4f79-98ad-45919d951aa0) +- **Date:** 2026-05-12T03:24:53Z +- **Document:** `/home/mike/code/FrenoCorp/agents/code-reviewer/reviews/FRE-4762-review.md` + +### Findings Verified + +| Severity | Count | Details | +|----------|-------|---------| +| P1 Critical | 0 | None | +| P2 High | 1 | ListMessages uses POST with method override (non-blocking, known pattern) | +| P3 Minor | 2 | Redundant Body field, UpdateDraft structure | + +### Contract Compliance ✅ + +- ✅ All endpoint paths use `/mail/v4/` prefix +- ✅ HTTP methods properly used (GET, POST, PUT, DELETE) +- ✅ Response structures match API spec +- ✅ Error handling consistent and proper +- ✅ Resource cleanup correct + +## Final Disposition + +**Status:** `done` + +The implementation has been reviewed, approved, and verified against the go-proton-api v4 contract. All acceptance criteria met. + +--- +*Generated: 2026-05-12T03:35:00Z* \ No newline at end of file diff --git a/internal/mail/client.go b/internal/mail/client.go index e39d4b7..e5cf230 100644 --- a/internal/mail/client.go +++ b/internal/mail/client.go @@ -7,6 +7,8 @@ import ( "io" "net/http" "net/url" + "os" + "time" "github.com/frenocorp/pop/internal/api" ) @@ -390,3 +392,335 @@ func (c *Client) SearchMessages(req SearchRequest) (*SearchResponse, error) { return &result, nil } + +func (c *Client) ListConversations(page int, pageSize int, passphrase string) (*ConversationResponse, error) { + body := map[string]interface{}{ + "Page": page, + "PageSize": pageSize, + "Passphrase": passphrase, + } + + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + reqURL := fmt.Sprintf("%s/mail/v4/conversations", c.baseURL) + httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("X-HTTP-Method-Override", "GET") + + resp, err := c.apiClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to list conversations: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var result ConversationResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &result, nil +} + +func (c *Client) GetConversation(req GetConversationRequest) (*GetConversationResponse, error) { + body := map[string]interface{}{ + "Passphrase": req.Passphrase, + } + + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + reqURL := fmt.Sprintf("%s/mail/v4/conversations/%s", c.baseURL, url.QueryEscape(req.ConversationID)) + httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("X-HTTP-Method-Override", "GET") + + resp, err := c.apiClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to get conversation: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var result GetConversationResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &result, nil +} + +func (c *Client) BulkDelete(messageIDs []string) (*BulkResponse, error) { + body := map[string]interface{}{ + "MessageIDs": messageIDs, + } + + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + reqURL := fmt.Sprintf("%s/mail/v4/messages/bulk", c.baseURL) + httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("X-HTTP-Method-Override", "DELETE") + + resp, err := c.apiClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to bulk delete: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var result BulkResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &result, nil +} + +func (c *Client) BulkTrash(messageIDs []string, passphrase string) (*BulkResponse, error) { + body := map[string]interface{}{ + "MessageIDs": messageIDs, + "Passphrase": passphrase, + } + + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + reqURL := fmt.Sprintf("%s/mail/v4/messages/bulk/trash", c.baseURL) + httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.apiClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to bulk trash: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var result BulkResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &result, nil +} + +func (c *Client) BulkStar(messageIDs []string, starred bool) (*BulkResponse, error) { + body := map[string]interface{}{ + "MessageIDs": messageIDs, + "Starred": starred, + } + + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + reqURL := fmt.Sprintf("%s/mail/v4/messages/bulk/star", c.baseURL) + httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.apiClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to bulk star: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var result BulkResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &result, nil +} + +func (c *Client) BulkMarkRead(messageIDs []string, read bool) (*BulkResponse, error) { + body := map[string]interface{}{ + "MessageIDs": messageIDs, + "Read": read, + } + + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + reqURL := fmt.Sprintf("%s/mail/v4/messages/bulk/read", c.baseURL) + httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.apiClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to bulk mark read: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var result BulkResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &result, nil +} + +func (c *Client) ExportMessages(req ExportRequest) ([]ExportedMessage, error) { + var messages []Message + + if len(req.MessageIDs) > 0 { + for _, id := range req.MessageIDs { + msg, err := c.GetMessage(id, req.Passphrase) + if err != nil { + return nil, fmt.Errorf("failed to get message %s: %w", id, err) + } + messages = append(messages, *msg) + } + } else if req.Search != "" { + searchReq := SearchRequest{ + Query: req.Search, + Page: 1, + PageSize: 100, + Passphrase: req.Passphrase, + } + searchResult, err := c.SearchMessages(searchReq) + if err != nil { + return nil, fmt.Errorf("failed to search messages: %w", err) + } + messages = searchResult.Messages + } else { + listReq := ListMessagesRequest{ + Folder: req.Folder, + Page: 1, + PageSize: 100, + Passphrase: req.Passphrase, + } + if req.Since > 0 { + listReq.Since = req.Since + } + listResult, err := c.ListMessages(listReq) + if err != nil { + return nil, fmt.Errorf("failed to list messages: %w", err) + } + messages = listResult.Messages + } + + exported := make([]ExportedMessage, 0, len(messages)) + for _, msg := range messages { + exp := ExportedMessage{ + MessageID: msg.MessageID, + ConversationID: msg.ConversationID, + From: msg.Sender, + To: msg.Recipients, + Subject: msg.Subject, + Body: msg.Body, + Date: msg.CreatedAt.Format(time.RFC3339), + Starred: msg.Starred, + Read: msg.Read, + Attachments: msg.Attachments, + } + exported = append(exported, exp) + } + + return exported, nil +} + +func (c *Client) ImportMessages(req ImportRequest) (*ImportResponse, error) { + fileData, err := os.ReadFile(req.FilePath) + if err != nil { + return nil, fmt.Errorf("failed to read import file: %w", err) + } + + var messages []ExportedMessage + if req.Format == ExportFormatJSON { + if err := json.Unmarshal(fileData, &messages); err != nil { + return nil, fmt.Errorf("failed to parse import file: %w", err) + } + } else { + return nil, fmt.Errorf("unsupported import format: %s (use json)", req.Format.String()) + } + + if len(messages) == 0 { + return &ImportResponse{Total: 0, ImportedCount: 0}, nil + } + + imported := 0 + var errors []BulkError + + for _, msg := range messages { + sendReq := SendRequest{ + To: []Recipient{msg.From.ToRecipient()}, + Subject: msg.Subject, + Body: msg.Body, + HTML: msg.HTML, + Passphrase: req.Passphrase, + } + + if err := c.Send(sendReq); err != nil { + errors = append(errors, BulkError{ + MessageID: msg.MessageID, + Error: err.Error(), + }) + continue + } + imported++ + } + + return &ImportResponse{ + ImportedCount: imported, + Total: len(messages), + Errors: errors, + }, nil +} diff --git a/internal/mail/client_test.go b/internal/mail/client_test.go index 98aa6e1..0af4412 100644 --- a/internal/mail/client_test.go +++ b/internal/mail/client_test.go @@ -32,22 +32,25 @@ func newMockServer(t *testing.T) *mockServer { server: srv, } - mux.HandleFunc("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) { resolveHandler(ms, w, r) }) - mux.HandleFunc("POST /api/messages/search", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("POST /mail/v4/messages/search", func(w http.ResponseWriter, r *http.Request) { resolveHandler(ms, w, r) }) - mux.HandleFunc("POST /api/messages/{id}", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("GET /mail/v4/messages/{id}", func(w http.ResponseWriter, r *http.Request) { resolveHandler(ms, w, r) }) - mux.HandleFunc("POST /api/messages/{id}/movetotrash", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("PUT /mail/v4/messages/{id}", func(w http.ResponseWriter, r *http.Request) { resolveHandler(ms, w, r) }) - mux.HandleFunc("POST /api/messages/{id}/delete", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("PUT /mail/v4/messages/{id}/trash", func(w http.ResponseWriter, r *http.Request) { resolveHandler(ms, w, r) }) - mux.HandleFunc("POST /api/messages/{id}/send", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("DELETE /mail/v4/messages/{id}", func(w http.ResponseWriter, r *http.Request) { + resolveHandler(ms, w, r) + }) + mux.HandleFunc("POST /mail/v4/messages/{id}", func(w http.ResponseWriter, r *http.Request) { resolveHandler(ms, w, r) }) @@ -72,8 +75,8 @@ func resolveHandler(ms *mockServer, w http.ResponseWriter, r *http.Request) { handler.(http.HandlerFunc)(w, r) return } - // When POST /api/messages is called, it matches both list and send/draft. - // The generic handler for POST /api/messages catches all unmatched POST /api/messages calls. + // When POST /mail/v4/messages is called, it matches both list and send/draft. + // The generic handler for POST /mail/v4/messages catches all unmatched POST /mail/v4/messages calls. w.WriteHeader(http.StatusNotFound) fmt.Fprintf(w, `{"Code":404,"Message":"not found"}`) } @@ -129,7 +132,7 @@ func TestListMessages_Success(t *testing.T) { }, } - srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) { body := readJSON(t, r) if body["Page"] != float64(1) { t.Errorf("expected page 1, got %v", body["Page"]) @@ -167,7 +170,7 @@ func TestListMessages_WithFolderFilter(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) { body := readJSON(t, r) if body["Type"] != float64(FolderSent) { t.Errorf("expected Type=3 (sent), got %v", body["Type"]) @@ -191,7 +194,7 @@ func TestListMessages_InboxOmitsType(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) { body := readJSON(t, r) if _, ok := body["Type"]; ok { t.Error("Inbox should omit Type field") @@ -216,7 +219,7 @@ func TestListMessages_WithStarredFilter(t *testing.T) { client := newTestClient(t, srv) starred := true - srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) { body := readJSON(t, r) if body["Starred"] != true { t.Errorf("expected Starred=true, got %v", body["Starred"]) @@ -242,7 +245,7 @@ func TestListMessages_WithReadFilter(t *testing.T) { client := newTestClient(t, srv) unread := false - srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) { body := readJSON(t, r) if body["Read"] != false { t.Errorf("expected Read=false, got %v", body["Read"]) @@ -268,7 +271,7 @@ func TestListMessages_WithSinceFilter(t *testing.T) { client := newTestClient(t, srv) since := int64(1700000000) - srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) { body := readJSON(t, r) if body["Since"] != float64(since) { t.Errorf("expected Since=%d, got %v", since, body["Since"]) @@ -293,7 +296,7 @@ func TestListMessages_APIError(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) fmt.Fprintf(w, `{"Code":403,"Message":"invalid token"}`) }) @@ -316,7 +319,7 @@ func TestListMessages_BadJSON(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) fmt.Fprint(w, `{"bad json`) }) @@ -348,12 +351,8 @@ func TestGetMessage_Success(t *testing.T) { Body: "Decrypted body content", } - srv.Handle("POST /api/messages/msg-42", func(w http.ResponseWriter, r *http.Request) { - body := readJSON(t, r) - if body["Passphrase"] != "pass" { - t.Errorf("expected passphrase pass, got %v", body["Passphrase"]) - } - writeJSON(t, w, http.StatusOK, map[string]interface{}{"Data": expectedMsg}) + srv.Handle("GET /mail/v4/messages/msg-42", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, map[string]interface{}{"Message": expectedMsg}) }) msg, err := client.GetMessage("msg-42", "pass") @@ -377,8 +376,8 @@ func TestGetMessage_URLEscape(t *testing.T) { client := newTestClient(t, srv) msgID := "msg/with/slashes" - srv.Handle("POST /api/messages/msg/with/slashes", func(w http.ResponseWriter, r *http.Request) { - writeJSON(t, w, http.StatusOK, map[string]interface{}{"Data": Message{MessageID: msgID}}) + srv.Handle("GET /mail/v4/messages/msg/with/slashes", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, map[string]interface{}{"Message": Message{MessageID: msgID}}) }) msg, err := client.GetMessage(msgID, "pass") @@ -395,7 +394,7 @@ func TestGetMessage_NotFound(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - srv.Handle("POST /api/messages/msg-999", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("GET /mail/v4/messages/msg-999", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) fmt.Fprintf(w, `{"Code":404,"Message":"message not found"}`) }) @@ -437,8 +436,8 @@ func TestGetMessage_DecryptBody(t *testing.T) { BodyEnc: encryptedBody, } - srv.Handle("POST /api/messages/msg-enc", func(w http.ResponseWriter, r *http.Request) { - writeJSON(t, w, http.StatusOK, map[string]interface{}{"Data": msgWithEncryptedBody}) + srv.Handle("GET /mail/v4/messages/msg-enc", func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, map[string]interface{}{"Message": msgWithEncryptedBody}) }) msg, err := client.GetMessage("msg-enc", pass) @@ -457,7 +456,7 @@ func TestSend_Success(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) { body := readJSON(t, r) if body["Subject"] != "Test Subject" { t.Errorf("expected subject Test Subject, got %v", body["Subject"]) @@ -496,7 +495,7 @@ func TestSend_WithPGP(t *testing.T) { client := NewClient(apiClient) client.SetPGPService(svc) - srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) { body := readJSON(t, r) // When PGP service is set, BodyEnc should be present instead of Body if _, hasBody := body["Body"]; hasBody { @@ -524,7 +523,7 @@ func TestSend_WithCC(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) { body := readJSON(t, r) cc, ok := body["CC"].([]interface{}) if !ok || len(cc) != 1 { @@ -549,7 +548,7 @@ func TestSend_WithBCC(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) { body := readJSON(t, r) bcc, ok := body["BCC"].([]interface{}) if !ok || len(bcc) != 1 { @@ -574,7 +573,7 @@ func TestSend_HTTPError(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) fmt.Fprintf(w, `{"Code":400,"Message":"invalid recipient"}`) }) @@ -598,7 +597,7 @@ func TestSend_CreatedStatus(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) fmt.Fprintf(w, `{"Data":{"MessageID":"created-1"}}`) }) @@ -620,13 +619,13 @@ func TestMoveToTrash_Success(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - srv.Handle("POST /api/messages/msg-1/movetotrash", func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" { - t.Error("expected form-urlencoded content type") + srv.Handle("PUT /mail/v4/messages/msg-1/trash", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Content-Type") != "application/json" { + t.Error("expected json content type") } body, _ := io.ReadAll(r.Body) - if !strings.Contains(string(body), "Passphrase=pass") { - t.Errorf("expected Passphrase in form body, got %s", body) + if !strings.Contains(string(body), `"Passphrase":"pass"`) { + t.Errorf("expected Passphrase in json body, got %s", body) } w.WriteHeader(http.StatusOK) }) @@ -642,7 +641,7 @@ func TestMoveToTrash_Error(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - srv.Handle("POST /api/messages/msg-1/movetotrash", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("PUT /mail/v4/messages/msg-1/trash", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) fmt.Fprint(w, "server error") }) @@ -663,7 +662,7 @@ func TestPermanentlyDelete_Success(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - srv.Handle("POST /api/messages/msg-1/delete", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("DELETE /mail/v4/messages/msg-1", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) @@ -678,7 +677,7 @@ func TestPermanentlyDelete_Error(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - srv.Handle("POST /api/messages/msg-1/delete", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("DELETE /mail/v4/messages/msg-1", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) fmt.Fprintf(w, `{"Code":404,"Message":"not found"}`) }) @@ -699,7 +698,7 @@ func TestPermanentlyDelete_URLEscape(t *testing.T) { client := newTestClient(t, srv) msgID := "msg/with/slashes" - srv.Handle("POST /api/messages/msg/with/slashes/delete", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("DELETE /mail/v4/messages/msg/with/slashes", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) @@ -716,7 +715,7 @@ func TestSaveDraft_Success(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) { body := readJSON(t, r) if body["Type"] != MessageTypeDraft { t.Errorf("expected Type=%s, got %v", MessageTypeDraft, body["Type"]) @@ -725,7 +724,7 @@ func TestSaveDraft_Success(t *testing.T) { t.Errorf("expected subject Draft Subject, got %v", body["Subject"]) } writeJSON(t, w, http.StatusOK, map[string]interface{}{ - "Data": map[string]string{"MessageID": "draft-1"}, + "Message": map[string]string{"MessageID": "draft-1"}, }) }) @@ -747,7 +746,7 @@ func TestSaveDraft_WithCC(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) { body := readJSON(t, r) if _, ok := body["CC"]; !ok { t.Error("expected CC field") @@ -777,7 +776,7 @@ func TestSaveDraft_Error(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) fmt.Fprintf(w, `{"Code":400,"Message":"invalid recipient"}`) }) @@ -797,7 +796,7 @@ func TestSaveDraft_BadJSON(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) fmt.Fprint(w, `{"bad json`) }) @@ -822,10 +821,11 @@ func TestUpdateDraft_Success(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - srv.Handle("POST /api/messages/draft-1", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("PUT /mail/v4/messages/draft-1", func(w http.ResponseWriter, r *http.Request) { body := readJSON(t, r) - if body["Subject"] != "Updated Subject" { - t.Errorf("expected Updated Subject, got %v", body["Subject"]) + msg := body["Message"].(map[string]interface{}) + if msg["Subject"] != "Updated Subject" { + t.Errorf("expected Updated Subject, got %v", msg["Subject"]) } w.WriteHeader(http.StatusOK) }) @@ -845,9 +845,10 @@ func TestUpdateDraft_WithCC(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - srv.Handle("POST /api/messages/draft-1", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("PUT /mail/v4/messages/draft-1", func(w http.ResponseWriter, r *http.Request) { body := readJSON(t, r) - if _, ok := body["CC"]; !ok { + msg := body["Message"].(map[string]interface{}) + if _, ok := msg["CC"]; !ok { t.Error("expected CC field in update") } w.WriteHeader(http.StatusOK) @@ -869,7 +870,7 @@ func TestUpdateDraft_Error(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - srv.Handle("POST /api/messages/draft-1", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("PUT /mail/v4/messages/draft-1", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusConflict) fmt.Fprintf(w, `{"Code":409,"Message":"draft locked"}`) }) @@ -895,13 +896,13 @@ func TestSendDraft_Success(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - srv.Handle("POST /api/messages/draft-1/send", func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" { - t.Error("expected form-urlencoded content type") + srv.Handle("POST /mail/v4/messages/draft-1", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Content-Type") != "application/json" { + t.Error("expected json content type") } body, _ := io.ReadAll(r.Body) - if !strings.Contains(string(body), "Passphrase=pass") { - t.Errorf("expected Passphrase in form, got %s", body) + if !strings.Contains(string(body), `"Passphrase":"pass"`) { + t.Errorf("expected Passphrase in json, got %s", body) } w.WriteHeader(http.StatusOK) }) @@ -917,7 +918,7 @@ func TestSendDraft_Error(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - srv.Handle("POST /api/messages/draft-1/send", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages/draft-1", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) fmt.Fprint(w, "not found") }) @@ -945,7 +946,7 @@ func TestListDrafts_Success(t *testing.T) { }, } - srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) { body := readJSON(t, r) if body["Type"] != float64(FolderDraft) { t.Errorf("expected Type=2 (draft), got %v", body["Type"]) @@ -978,7 +979,7 @@ func TestSearchMessages_Success(t *testing.T) { }, } - srv.Handle("POST /api/messages/search", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages/search", func(w http.ResponseWriter, r *http.Request) { body := readJSON(t, r) if body["Query"] != "invoice" { t.Errorf("expected query invoice, got %v", body["Query"]) @@ -1014,7 +1015,7 @@ func TestSearchMessages_EmptyResults(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - srv.Handle("POST /api/messages/search", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages/search", func(w http.ResponseWriter, r *http.Request) { writeJSON(t, w, http.StatusOK, SearchResponse{Total: 0, Messages: []Message{}}) }) @@ -1037,7 +1038,7 @@ func TestSearchMessages_APIError(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - srv.Handle("POST /api/messages/search", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages/search", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTooManyRequests) fmt.Fprintf(w, `{"Code":429,"Message":"rate limited"}`) }) @@ -1061,7 +1062,7 @@ func TestSearchMessages_BadJSON(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - srv.Handle("POST /api/messages/search", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages/search", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) fmt.Fprint(w, `not json at all`) }) @@ -1097,7 +1098,7 @@ func TestAuthHeader_Propagated(t *testing.T) { client := NewClient(apiClient) var capturedAuth string - srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) { capturedAuth = r.Header.Get("Authorization") writeJSON(t, w, http.StatusOK, ListMessagesResponse{}) }) @@ -1120,7 +1121,7 @@ func TestContentTypes_JSON(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) { ct := r.Header.Get("Content-Type") if ct != "application/json" { t.Errorf("expected application/json, got %s", ct) @@ -1140,10 +1141,10 @@ func TestContentTypes_FormUrlEncoded(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - srv.Handle("POST /api/messages/msg-1/movetotrash", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("PUT /mail/v4/messages/msg-1/trash", func(w http.ResponseWriter, r *http.Request) { ct := r.Header.Get("Content-Type") - if ct != "application/x-www-form-urlencoded" { - t.Errorf("expected application/x-www-form-urlencoded, got %s", ct) + if ct != "application/json" { + t.Errorf("expected application/json, got %s", ct) } w.WriteHeader(http.StatusOK) }) @@ -1161,7 +1162,7 @@ func TestListMessages_Concurrent(t *testing.T) { var mu sync.Mutex callCount := 0 - srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) { mu.Lock() callCount++ mu.Unlock() @@ -1267,7 +1268,7 @@ func TestSetPGPService(t *testing.T) { svc, _, _ := newTestService(t) client.SetPGPService(svc) - srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) { body := readJSON(t, r) // PGP service should cause BodyEnc instead of Body if _, hasBody := body["Body"]; hasBody { @@ -1294,7 +1295,7 @@ func TestSend_WithoutBody(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) { body := readJSON(t, r) if _, hasBody := body["Body"]; hasBody { t.Error("Body should be omitted when empty") @@ -1331,7 +1332,7 @@ func TestListMessages_Timeout(t *testing.T) { apiClient.SetAuthHeader("test-token") client := NewClient(apiClient) - srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) { time.Sleep(2 * time.Second) writeJSON(t, w, http.StatusOK, ListMessagesResponse{}) }) @@ -1357,7 +1358,7 @@ func TestListMessages_CombinedFilters(t *testing.T) { unread := false since := int64(1700000000) - srv.Handle("POST /api/messages", func(w http.ResponseWriter, r *http.Request) { + srv.Handle("POST /mail/v4/messages", func(w http.ResponseWriter, r *http.Request) { body := readJSON(t, r) if body["Type"] != float64(FolderSent) { t.Errorf("expected Type=3, got %v", body["Type"]) diff --git a/internal/mail/types.go b/internal/mail/types.go index 21afe70..5f977b7 100644 --- a/internal/mail/types.go +++ b/internal/mail/types.go @@ -78,6 +78,13 @@ func (r Recipient) DisplayName() string { return r.Address } +func (r Recipient) ToRecipient() Recipient { + return Recipient{ + Name: r.Name, + Address: r.Address, + } +} + type Attachment struct { AttachmentID string `json:"AttachmentID"` Name string `json:"Name"` @@ -118,15 +125,17 @@ type ListMessagesResponse struct { } type SendRequest struct { - To []Recipient `json:"To"` - CC []Recipient `json:"CC,omitempty"` - BCC []Recipient `json:"BCC,omitempty"` - Subject string `json:"Subject"` - Body string `json:"Body"` - HTML bool `json:"HTML,omitempty"` - ReplyTo []Recipient `json:"ReplyTo,omitempty"` + To []Recipient `json:"To"` + CC []Recipient `json:"CC,omitempty"` + BCC []Recipient `json:"BCC,omitempty"` + Subject string `json:"Subject"` + Body string `json:"Body"` + HTML bool `json:"HTML,omitempty"` + ReplyTo []Recipient `json:"ReplyTo,omitempty"` + InReplyTo string `json:"InReplyTo,omitempty"` + References string `json:"References,omitempty"` Attachments []Attachment `json:"Attachments,omitempty"` - Passphrase string `json:"Passphrase"` + Passphrase string `json:"Passphrase"` } type SearchRequest struct { @@ -140,3 +149,118 @@ type SearchResponse struct { Total int `json:"Total"` Messages []Message `json:"Messages"` } + +// Conversation represents a threaded conversation (email thread) +type Conversation struct { + ConversationID string `json:"ConversationID"` + Subject string `json:"Subject"` + MessageCount int `json:"MessageCount"` + LastMessage *Message `json:"LastMessage"` + Participants []Recipient `json:"Participants"` +} + +type ConversationResponse struct { + Total int `json:"Total"` + Conversations []Conversation `json:"Conversations"` +} + +type GetConversationRequest struct { + ConversationID string `json:"ConversationID"` + Page int `json:"Page"` + PageSize int `json:"PageSize"` + Passphrase string `json:"Passphrase"` +} + +type GetConversationResponse struct { + ConversationID string `json:"ConversationID"` + Subject string `json:"Subject"` + MessageCount int `json:"MessageCount"` + Messages []Message `json:"Messages"` + Participants []Recipient `json:"Participants"` +} + +// BulkRequest represents a batch operation on multiple messages +type BulkRequest struct { + MessageIDs []string `json:"MessageIDs"` + Passphrase string `json:"Passphrase"` +} + +type BulkResponse struct { + SuccessCount int `json:"SuccessCount"` + Total int `json:"Total"` + Errors []BulkError `json:"Errors,omitempty"` +} + +type BulkError struct { + MessageID string `json:"MessageID"` + Error string `json:"Error"` +} + +// ExportFormat represents the format for exporting messages +type ExportFormat int + +const ( + ExportFormatJSON ExportFormat = iota + ExportFormatMBOX + ExportFormatEMail +) + +func (f ExportFormat) String() string { + names := map[ExportFormat]string{ + ExportFormatJSON: "json", + ExportFormatMBOX: "mbox", + ExportFormatEMail: "eml", + } + if name, ok := names[f]; ok { + return name + } + return "json" +} + +// ExportRequest represents a message export request +type ExportRequest struct { + MessageIDs []string `json:"MessageIDs,omitempty"` + Folder Folder `json:"Folder,omitempty"` + Format ExportFormat `json:"Format"` + Since int64 `json:"Since,omitempty"` + Before int64 `json:"Before,omitempty"` + Search string `json:"Search,omitempty"` + Passphrase string `json:"Passphrase"` +} + +// ExportedMessage represents a message ready for export +type ExportedMessage struct { + MessageID string `json:"message_id"` + ConversationID string `json:"conversation_id"` + From Recipient `json:"from"` + To []Recipient `json:"to"` + CC []Recipient `json:"cc,omitempty"` + Subject string `json:"subject"` + Body string `json:"body"` + HTML bool `json:"html"` + Date string `json:"date"` + Starred bool `json:"starred"` + Read bool `json:"read"` + Attachments []Attachment `json:"attachments,omitempty"` +} + +// ImportRequest represents a message import request +type ImportRequest struct { + FilePath string `json:"FilePath"` + Format ExportFormat `json:"Format"` + Folder Folder `json:"Folder,omitempty"` + Passphrase string `json:"Passphrase"` +} + +type ImportResponse struct { + ImportedCount int `json:"ImportedCount"` + Total int `json:"Total"` + Errors []BulkError `json:"Errors,omitempty"` +} + +// DraftAutoSaveConfig holds auto-save settings for drafts +type DraftAutoSaveConfig struct { + Enabled bool `json:"enabled"` + Interval int `json:"interval_seconds"` + LastSaved int64 `json:"last_saved_timestamp"` +} diff --git a/internal/pgp/pgp.go b/internal/pgp/pgp.go new file mode 100644 index 0000000..b2e376d --- /dev/null +++ b/internal/pgp/pgp.go @@ -0,0 +1,465 @@ +package pgp + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + openpgp "github.com/ProtonMail/gopenpgp/v2/crypto" + "github.com/frenocorp/pop/internal/config" +) + +// ExternalKey represents an imported external PGP key. +type ExternalKey struct { + KeyID string `json:"key_id"` + Fingerprint string `json:"fingerprint"` + Emails []string `json:"emails"` + CreatedAt string `json:"created_at"` + ExpiresAt string `json:"expires_at,omitempty"` + IsSubkey bool `json:"is_subkey"` + CanEncrypt bool `json:"can_encrypt"` + CanSign bool `json:"can_sign"` + TrustLevel string `json:"trust_level"` + ArmorFile string `json:"armor_file"` +} + +// KeyStore manages external PGP keys. +type KeyStore struct { + configDir string + keysDir string + keysFile string +} + +// NewKeyStore creates a new PGP key store. +func NewKeyStore() (*KeyStore, error) { + cfg := config.NewConfigManager() + configDir := cfg.ConfigDir() + + keysDir := filepath.Join(configDir, "pgp_keys") + if err := os.MkdirAll(keysDir, 0700); err != nil { + return nil, fmt.Errorf("failed to create PGP keys directory: %w", err) + } + + return &KeyStore{ + configDir: configDir, + keysDir: keysDir, + keysFile: filepath.Join(configDir, "pgp_keys.json"), + }, nil +} + +// ImportKey imports an external PGP key from armored ASCII text. +func (ks *KeyStore) ImportKey(armor string, trustLevel string) (*ExternalKey, error) { + if trustLevel == "" { + trustLevel = "unknown" + } + + pgpKey, err := openpgp.NewKeyFromArmored(armor) + if err != nil { + return nil, fmt.Errorf("failed to parse PGP key: %w", err) + } + + fingerprint := pgpKey.GetFingerprint() + keyID := fingerprint[len(fingerprint)-8:] + + emails := []string{} + if entity := pgpKey.GetEntity(); entity != nil { + for _, uid := range entity.Identities { + if uid != nil && uid.UserId != nil { + emails = append(emails, uid.UserId.Email) + } + } + } + + expiresAt := "" + if pgpKey.GetEntity() != nil { + expiresAt = time.Unix(int64(pgpKey.GetEntity().PrimaryKey.CreationTime.Unix()), 0).UTC().Format(time.RFC3339) + } + + key := &ExternalKey{ + KeyID: keyID, + Fingerprint: fingerprint, + Emails: emails, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + ExpiresAt: expiresAt, + IsSubkey: false, + CanEncrypt: pgpKey.CanEncrypt(), + CanSign: pgpKey.IsPrivate(), + TrustLevel: trustLevel, + ArmorFile: filepath.Join(ks.keysDir, keyID+".asc"), + } + + if err := os.WriteFile(key.ArmorFile, []byte(armor), 0600); err != nil { + return nil, fmt.Errorf("failed to write key file: %w", err) + } + + if err := ks.saveKeyMetadata(key); err != nil { + os.Remove(key.ArmorFile) + return nil, err + } + + return key, nil +} + +// ImportKeyFromFile imports a PGP key from a file containing armored ASCII. +func (ks *KeyStore) ImportKeyFromFile(filePath string, trustLevel string) (*ExternalKey, error) { + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read key file: %w", err) + } + return ks.ImportKey(string(data), trustLevel) +} + +// ListKeys returns all imported external keys. +func (ks *KeyStore) ListKeys() ([]ExternalKey, error) { + data, err := os.ReadFile(ks.keysFile) + if err != nil { + if os.IsNotExist(err) { + return []ExternalKey{}, nil + } + return nil, fmt.Errorf("failed to read keys metadata: %w", err) + } + + var keys []ExternalKey + if err := json.Unmarshal(data, &keys); err != nil { + return nil, fmt.Errorf("failed to parse keys metadata: %w", err) + } + + return keys, nil +} + +// GetKey retrieves a key by key ID or fingerprint. +func (ks *KeyStore) GetKey(identifier string) (*ExternalKey, error) { + keys, err := ks.ListKeys() + if err != nil { + return nil, err + } + + for _, key := range keys { + if key.KeyID == identifier || key.Fingerprint == identifier { + return &key, nil + } + } + + return nil, fmt.Errorf("key %q not found", identifier) +} + +// RemoveKey removes an external key by key ID or fingerprint. +func (ks *KeyStore) RemoveKey(identifier string) error { + keys, err := ks.ListKeys() + if err != nil { + return err + } + + var keyToRemove *ExternalKey + newKeys := make([]ExternalKey, 0, len(keys)) + for i := range keys { + if keys[i].KeyID == identifier || keys[i].Fingerprint == identifier { + keyToRemove = &keys[i] + continue + } + newKeys = append(newKeys, keys[i]) + } + + if keyToRemove == nil { + return fmt.Errorf("key %q not found", identifier) + } + + if keyToRemove.ArmorFile != "" { + os.Remove(keyToRemove.ArmorFile) + } + + return ks.writeKeysMetadata(newKeys) +} + +// GetKeyArmor returns the armored ASCII representation of a key. +func (ks *KeyStore) GetKeyArmor(identifier string) (string, error) { + key, err := ks.GetKey(identifier) + if err != nil { + return "", err + } + + data, err := os.ReadFile(key.ArmorFile) + if err != nil { + return "", fmt.Errorf("failed to read key armor: %w", err) + } + + return string(data), nil +} + +// EncryptData encrypts plaintext using a public key. +func (ks *KeyStore) EncryptData(identifier, plaintext string) (string, error) { + armor, err := ks.GetKeyArmor(identifier) + if err != nil { + return "", err + } + + key, err := openpgp.NewKeyFromArmored(armor) + if err != nil { + return "", fmt.Errorf("failed to parse key for encryption: %w", err) + } + + if !key.CanEncrypt() { + return "", fmt.Errorf("key %s cannot be used for encryption", identifier) + } + + plainMsg := openpgp.NewPlainMessageFromString(plaintext) + keyRing, err := openpgp.NewKeyRing(key) + if err != nil { + return "", fmt.Errorf("failed to create keyring: %w", err) + } + + encryptedMsg, err := keyRing.Encrypt(plainMsg, keyRing) + if err != nil { + return "", fmt.Errorf("failed to encrypt data: %w", err) + } + + result, err := encryptedMsg.GetArmored() + if err != nil { + return "", fmt.Errorf("failed to get armored encrypted data: %w", err) + } + + return result, nil +} + +// SignData signs plaintext using a private key. +func (ks *KeyStore) SignData(identifier, plaintext, passphrase string) (string, error) { + armor, err := ks.GetKeyArmor(identifier) + if err != nil { + return "", err + } + + key, err := openpgp.NewKeyFromArmored(armor) + if err != nil { + return "", fmt.Errorf("failed to parse key for signing: %w", err) + } + + if passphrase != "" { + unlockedKey, unlockErr := key.Unlock([]byte(passphrase)) + if unlockErr != nil { + return "", fmt.Errorf("failed to unlock key: %w", unlockErr) + } + key = unlockedKey + } + + if !key.IsPrivate() { + return "", fmt.Errorf("key %s cannot be used for signing", identifier) + } + + keyRing, err := openpgp.NewKeyRing(key) + if err != nil { + return "", fmt.Errorf("failed to create keyring: %w", err) + } + + plainMsg := openpgp.NewPlainMessageFromString(plaintext) + signedMsg, err := keyRing.SignDetached(plainMsg) + if err != nil { + return "", fmt.Errorf("failed to sign data: %w", err) + } + + result, err := signedMsg.GetArmored() + if err != nil { + return "", fmt.Errorf("failed to get armored signature: %w", err) + } + + return result, nil +} + +// DecryptData decrypts PGP-encrypted data using a private key. +func (ks *KeyStore) DecryptData(identifier, encryptedData, passphrase string) (string, error) { + armor, err := ks.GetKeyArmor(identifier) + if err != nil { + return "", err + } + + key, err := openpgp.NewKeyFromArmored(armor) + if err != nil { + return "", fmt.Errorf("failed to parse key for decryption: %w", err) + } + + if passphrase != "" { + unlockedKey, unlockErr := key.Unlock([]byte(passphrase)) + if unlockErr != nil { + return "", fmt.Errorf("failed to unlock key: %w", unlockErr) + } + key = unlockedKey + } + + keyRing, err := openpgp.NewKeyRing(key) + if err != nil { + return "", fmt.Errorf("failed to create keyring: %w", err) + } + + encryptedMsg, parseErr := openpgp.NewPGPMessageFromArmored(encryptedData) + if parseErr != nil { + return "", fmt.Errorf("failed to parse encrypted message: %w", parseErr) + } + + plainMessage, err := keyRing.Decrypt(encryptedMsg, nil, 0) + if err != nil { + return "", fmt.Errorf("failed to decrypt data: %w", err) + } + + return string(plainMessage.Data), nil +} + +// VerifySignature verifies a detached signature. +func (ks *KeyStore) VerifySignature(keyID, message, signature string) (bool, error) { + armor, err := ks.GetKeyArmor(keyID) + if err != nil { + return false, err + } + + key, err := openpgp.NewKeyFromArmored(armor) + if err != nil { + return false, fmt.Errorf("failed to parse key for verification: %w", err) + } + + sig, err := openpgp.NewPGPSignatureFromArmored(signature) + if err != nil { + return false, fmt.Errorf("failed to parse signature: %w", err) + } + + keyRing, err := openpgp.NewKeyRing(key) + if err != nil { + return false, fmt.Errorf("failed to create keyring: %w", err) + } + + plainMsg := openpgp.NewPlainMessage([]byte(message)) + err = keyRing.VerifyDetached(plainMsg, sig, 0) + if err != nil { + return false, fmt.Errorf("signature verification failed: %w", err) + } + + return true, nil +} + +// TrustKey sets the trust level for a key. +func (ks *KeyStore) TrustKey(identifier, trustLevel string) error { + keys, err := ks.ListKeys() + if err != nil { + return err + } + + found := false + for i, key := range keys { + if key.KeyID == identifier || key.Fingerprint == identifier { + keys[i].TrustLevel = trustLevel + found = true + break + } + } + + if !found { + return fmt.Errorf("key %q not found", identifier) + } + + return ks.writeKeysMetadata(keys) +} + +// ExportKey exports a key to a file. +func (ks *KeyStore) ExportKey(identifier, outputPath string) error { + armor, err := ks.GetKeyArmor(identifier) + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + if err := os.WriteFile(outputPath, []byte(armor), 0600); err != nil { + return fmt.Errorf("failed to write exported key: %w", err) + } + + return nil +} + +// GetKeyFingerprint returns the fingerprint of a key from armored data. +func GetKeyFingerprint(armor string) (string, error) { + key, err := openpgp.NewKeyFromArmored(armor) + if err != nil { + return "", err + } + return key.GetFingerprint(), nil +} + +// ParseKeyInfo extracts key information from armored PGP data without importing. +func ParseKeyInfo(armor string) (*ExternalKey, error) { + key, err := openpgp.NewKeyFromArmored(armor) + if err != nil { + return nil, fmt.Errorf("failed to parse key: %w", err) + } + + fingerprint := key.GetFingerprint() + keyID := fingerprint[len(fingerprint)-8:] + + emails := []string{} + if entity := key.GetEntity(); entity != nil { + for _, uid := range entity.Identities { + if uid != nil && uid.UserId != nil { + emails = append(emails, uid.UserId.Email) + } + } + } + + expiresAt := "" + if entity := key.GetEntity(); entity != nil { + expiresAt = time.Unix(int64(entity.PrimaryKey.CreationTime.Unix()), 0).UTC().Format(time.RFC3339) + } + + return &ExternalKey{ + KeyID: keyID, + Fingerprint: fingerprint, + Emails: emails, + CreatedAt: expiresAt, + ExpiresAt: expiresAt, + IsSubkey: false, + CanEncrypt: key.CanEncrypt(), + CanSign: key.IsPrivate(), + }, nil +} + +func (ks *KeyStore) saveKeyMetadata(key *ExternalKey) error { + keys, err := ks.ListKeys() + if err != nil { + return err + } + + for _, k := range keys { + if k.KeyID == key.KeyID { + return fmt.Errorf("key with ID %s already imported", key.KeyID) + } + } + + keys = append(keys, *key) + return ks.writeKeysMetadata(keys) +} + +func (ks *KeyStore) writeKeysMetadata(keys []ExternalKey) error { + data, err := json.MarshalIndent(keys, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal keys metadata: %w", err) + } + + return os.WriteFile(ks.keysFile, data, 0600) +} + +// KeyFromReader reads PGP key data from an io.Reader (useful for stdin). +func (ks *KeyStore) KeyFromReader(reader io.Reader, trustLevel string) (*ExternalKey, error) { + data, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("failed to read key data: %w", err) + } + + content := string(data) + if !strings.Contains(content, "BEGIN PGP PUBLIC KEY BLOCK") && + !strings.Contains(content, "BEGIN PGP PRIVATE KEY BLOCK") { + return nil, fmt.Errorf("input does not appear to be a PGP key (missing armor header)") + } + + return ks.ImportKey(content, trustLevel) +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go new file mode 100644 index 0000000..7297790 --- /dev/null +++ b/internal/plugin/plugin.go @@ -0,0 +1,291 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/frenocorp/pop/internal/config" +) + +// Plugin represents a CLI plugin that can extend Pop's functionality. +type Plugin struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Author string `json:"author,omitempty"` + Binary string `json:"binary"` + InstalledAt string `json:"installed_at,omitempty"` + Source string `json:"source,omitempty"` + Commands []PluginCommand `json:"commands,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Enabled bool `json:"enabled"` +} + +// PluginCommand represents a command exposed by a plugin. +type PluginCommand struct { + Name string `json:"name"` + Description string `json:"description"` + Usage string `json:"usage,omitempty"` +} + +// PluginRegistry manages installed and available plugins. +type PluginRegistry struct { + configDir string + pluginsDir string + registryFile string +} + +// NewPluginRegistry creates a new plugin registry. +func NewPluginRegistry() (*PluginRegistry, error) { + cfg := config.NewConfigManager() + configDir := cfg.ConfigDir() + + pluginsDir := filepath.Join(configDir, "plugins") + if err := os.MkdirAll(pluginsDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create plugins directory: %w", err) + } + + return &PluginRegistry{ + configDir: configDir, + pluginsDir: pluginsDir, + registryFile: filepath.Join(configDir, "plugins.json"), + }, nil +} + +// ListPlugins returns all installed plugins. +func (pr *PluginRegistry) ListPlugins() ([]Plugin, error) { + data, err := os.ReadFile(pr.registryFile) + if err != nil { + if os.IsNotExist(err) { + return []Plugin{}, nil + } + return nil, fmt.Errorf("failed to read plugins registry: %w", err) + } + + var plugins []Plugin + if err := json.Unmarshal(data, &plugins); err != nil { + return nil, fmt.Errorf("failed to parse plugins registry: %w", err) + } + + return plugins, nil +} + +// GetPlugin retrieves a plugin by name. +func (pr *PluginRegistry) GetPlugin(name string) (*Plugin, error) { + plugins, err := pr.ListPlugins() + if err != nil { + return nil, err + } + + for _, p := range plugins { + if p.Name == name { + return &p, nil + } + } + + return nil, fmt.Errorf("plugin %q not found", name) +} + +// InstallPlugin installs a plugin binary and registers it. +func (pr *PluginRegistry) InstallPlugin(plugin Plugin) error { + if plugin.Name == "" { + return fmt.Errorf("plugin name is required") + } + if plugin.Binary == "" { + return fmt.Errorf("plugin binary path is required") + } + + plugins, err := pr.ListPlugins() + if err != nil { + return err + } + + for _, p := range plugins { + if p.Name == plugin.Name { + return fmt.Errorf("plugin %q is already installed (use --force to reinstall)", plugin.Name) + } + } + + plugin.Binary = filepath.Join(pr.pluginsDir, plugin.Binary) + plugin.InstalledAt = plugin.InstalledAt + + if err := os.MkdirAll(pr.pluginsDir, 0755); err != nil { + return fmt.Errorf("failed to create plugins directory: %w", err) + } + + if err := os.Chmod(plugin.Binary, 0755); err != nil { + return fmt.Errorf("failed to set executable permission: %w", err) + } + + plugins = append(plugins, plugin) + data, err := json.MarshalIndent(plugins, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal plugins registry: %w", err) + } + + if err := os.WriteFile(pr.registryFile, data, 0600); err != nil { + return fmt.Errorf("failed to write plugins registry: %w", err) + } + + return nil +} + +// UninstallPlugin removes a plugin. +func (pr *PluginRegistry) UninstallPlugin(name string) error { + plugins, err := pr.ListPlugins() + if err != nil { + return err + } + + var pluginToRemove *Plugin + newPlugins := make([]Plugin, 0, len(plugins)) + for _, p := range plugins { + if p.Name == name { + pluginToRemove = &p + continue + } + newPlugins = append(newPlugins, p) + } + + if pluginToRemove == nil { + return fmt.Errorf("plugin %q not found", name) + } + + if pluginToRemove.Binary != "" { + os.Remove(pluginToRemove.Binary) + } + + data, err := json.MarshalIndent(newPlugins, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal plugins registry: %w", err) + } + + return os.WriteFile(pr.registryFile, data, 0600) +} + +// ExecutePlugin runs a plugin with the given arguments. +func (pr *PluginRegistry) ExecutePlugin(name string, args []string) error { + plugin, err := pr.GetPlugin(name) + if err != nil { + return err + } + + if _, err := os.Stat(plugin.Binary); err != nil { + return fmt.Errorf("plugin binary not found at %s: %w", plugin.Binary, err) + } + + cmd := exec.Command(plugin.Binary, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + cmd.Env = append(os.Environ(), + "POP_PLUGIN_NAME="+plugin.Name, + "POP_PLUGIN_VERSION="+plugin.Version, + "POP_CONFIG_DIR="+pr.configDir, + ) + + return cmd.Run() +} + +// DiscoverPlugins scans the plugins directory for unregistered binaries. +func (pr *PluginRegistry) DiscoverPlugins() ([]string, error) { + entries, err := os.ReadDir(pr.pluginsDir) + if err != nil { + return nil, err + } + + var binaries []string + for _, entry := range entries { + if entry.IsDir() { + continue + } + if strings.HasPrefix(entry.Name(), ".") { + continue + } + if runtime.GOOS == "windows" && !strings.HasSuffix(entry.Name(), ".exe") { + continue + } + if runtime.GOOS != "windows" && strings.HasSuffix(entry.Name(), ".exe") { + continue + } + binaries = append(binaries, entry.Name()) + } + + return binaries, nil +} + +// PluginBinaryPath returns the expected binary path for a plugin name. +func (pr *PluginRegistry) PluginBinaryPath(name string) string { + binary := "pop-" + name + if runtime.GOOS == "windows" { + binary += ".exe" + } + return filepath.Join(pr.pluginsDir, binary) +} + +// PluginsDir returns the plugins directory path. +func (pr *PluginRegistry) PluginsDir() string { + return pr.pluginsDir +} + +// EnablePlugin enables a plugin by name. +func (pr *PluginRegistry) EnablePlugin(name string) error { + plugins, err := pr.ListPlugins() + if err != nil { + return err + } + + found := false + for i, p := range plugins { + if p.Name == name { + plugins[i].Enabled = true + found = true + break + } + } + + if !found { + return fmt.Errorf("plugin %q not found", name) + } + + data, err := json.MarshalIndent(plugins, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal plugins registry: %w", err) + } + + return os.WriteFile(pr.registryFile, data, 0600) +} + +// DisablePlugin disables a plugin by name. +func (pr *PluginRegistry) DisablePlugin(name string) error { + plugins, err := pr.ListPlugins() + if err != nil { + return err + } + + found := false + for i, p := range plugins { + if p.Name == name { + plugins[i].Enabled = false + found = true + break + } + } + + if !found { + return fmt.Errorf("plugin %q not found", name) + } + + data, err := json.MarshalIndent(plugins, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal plugins registry: %w", err) + } + + return os.WriteFile(pr.registryFile, data, 0600) +} diff --git a/internal/webhook/webhook.go b/internal/webhook/webhook.go new file mode 100644 index 0000000..e7a4f59 --- /dev/null +++ b/internal/webhook/webhook.go @@ -0,0 +1,375 @@ +package webhook + +import ( + "bytes" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "sync" + "time" + + "github.com/frenocorp/pop/internal/config" +) + +// EventType represents a mail event that can trigger a webhook. +type EventType string + +const ( + // EventReceived is triggered when a new message arrives. + EventReceived EventType = "mail.received" + // EventSent is triggered when a message is sent. + EventSent EventType = "mail.sent" + // EventDeleted is triggered when a message is permanently deleted. + EventDeleted EventType = "mail.deleted" + // EventTrashed is triggered when a message is moved to trash. + EventTrashed EventType = "mail.trashed" + // EventStarred is triggered when a message is starred or unstarred. + EventStarred EventType = "mail.starred" + // EventLabeled is triggered when a label is applied or removed. + EventLabeled EventType = "mail.labeled" + // EventFolderMoved is triggered when a message is moved to a different folder. + EventFolderMoved EventType = "mail.folder_moved" +) + +// AllEventTypes lists all supported event types. +var AllEventTypes = []EventType{ + EventReceived, EventSent, EventDeleted, EventTrashed, + EventStarred, EventLabeled, EventFolderMoved, +} + +// Webhook represents a webhook subscription. +type Webhook struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Events []string `json:"events"` + Secret string `json:"secret,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Active bool `json:"active"` + CreatedAt string `json:"created_at"` + LastTriggeredAt string `json:"last_triggered_at,omitempty"` + LastStatus int `json:"last_status,omitempty"` + RetryCount int `json:"retry_count"` + MaxRetries int `json:"max_retries"` + TimeoutSec int `json:"timeout_sec"` +} + +// WebhookEvent represents a webhook payload sent to the target URL. +type WebhookEvent struct { + ID string `json:"id"` + Type string `json:"type"` + Timestamp string `json:"timestamp"` + Account string `json:"account,omitempty"` + Data map[string]interface{} `json:"data"` +} + +// WebhookStore manages webhook subscriptions. +type WebhookStore struct { + configDir string + webhooksFile string + mu sync.RWMutex +} + +// NewWebhookStore creates a new webhook store. +func NewWebhookStore() (*WebhookStore, error) { + cfg := config.NewConfigManager() + configDir := cfg.ConfigDir() + + return &WebhookStore{ + configDir: configDir, + webhooksFile: filepath.Join(configDir, "webhooks.json"), + }, nil +} + +// ListWebhooks returns all webhook subscriptions. +func (ws *WebhookStore) ListWebhooks() ([]Webhook, error) { + ws.mu.RLock() + defer ws.mu.RUnlock() + + data, err := os.ReadFile(ws.webhooksFile) + if err != nil { + if os.IsNotExist(err) { + return []Webhook{}, nil + } + return nil, fmt.Errorf("failed to read webhooks file: %w", err) + } + + var webhooks []Webhook + if err := json.Unmarshal(data, &webhooks); err != nil { + return nil, fmt.Errorf("failed to parse webhooks: %w", err) + } + + return webhooks, nil +} + +// GetWebhook retrieves a webhook by ID. +func (ws *WebhookStore) GetWebhook(id string) (*Webhook, error) { + webhooks, err := ws.ListWebhooks() + if err != nil { + return nil, err + } + + for _, wh := range webhooks { + if wh.ID == id { + return &wh, nil + } + } + + return nil, fmt.Errorf("webhook %q not found", id) +} + +// AddWebhook creates a new webhook subscription. +func (ws *WebhookStore) AddWebhook(name, url string, events []EventType, secret string) (*Webhook, error) { + ws.mu.Lock() + defer ws.mu.Unlock() + + webhooks, err := ws.loadWebhooks() + if err != nil { + return nil, err + } + + if url == "" { + return nil, fmt.Errorf("webhook URL is required") + } + + if len(events) == 0 { + return nil, fmt.Errorf("at least one event type is required") + } + + for _, event := range events { + found := false + for _, valid := range AllEventTypes { + if event == valid { + found = true + break + } + } + if !found { + return nil, fmt.Errorf("unknown event type: %s (valid: %v)", event, AllEventTypes) + } + } + + if secret == "" { + secret = generateSecret() + } + + wh := Webhook{ + ID: generateID(), + Name: name, + URL: url, + Events: eventTypeStrings(events), + Secret: secret, + Headers: make(map[string]string), + Active: true, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + MaxRetries: 3, + TimeoutSec: 30, + } + + webhooks = append(webhooks, wh) + if err := ws.saveWebhooks(webhooks); err != nil { + return nil, err + } + + return &wh, nil +} + +// UpdateWebhook updates an existing webhook subscription. +func (ws *WebhookStore) UpdateWebhook(id string, url, name *string, active *bool, events *[]string) (*Webhook, error) { + ws.mu.Lock() + defer ws.mu.Unlock() + + webhooks, err := ws.loadWebhooks() + if err != nil { + return nil, err + } + + for i, wh := range webhooks { + if wh.ID == id { + if url != nil { + webhooks[i].URL = *url + } + if name != nil { + webhooks[i].Name = *name + } + if active != nil { + webhooks[i].Active = *active + } + if events != nil { + webhooks[i].Events = *events + } + + if err := ws.saveWebhooks(webhooks); err != nil { + return nil, err + } + return &webhooks[i], nil + } + } + + return nil, fmt.Errorf("webhook %q not found", id) +} + +// RemoveWebhook deletes a webhook subscription. +func (ws *WebhookStore) RemoveWebhook(id string) error { + ws.mu.Lock() + defer ws.mu.Unlock() + + webhooks, err := ws.loadWebhooks() + if err != nil { + return err + } + + newWebhooks := make([]Webhook, 0, len(webhooks)) + found := false + for _, wh := range webhooks { + if wh.ID == id { + found = true + continue + } + newWebhooks = append(newWebhooks, wh) + } + + if !found { + return fmt.Errorf("webhook %q not found", id) + } + + return ws.saveWebhooks(newWebhooks) +} + +// TriggerWebhook sends a webhook event to the configured URL. +func (ws *WebhookStore) TriggerWebhook(wh *Webhook, eventType EventType, data map[string]interface{}) error { + if !wh.Active { + return nil + } + + event := WebhookEvent{ + ID: generateID(), + Type: string(eventType), + Timestamp: time.Now().UTC().Format(time.RFC3339), + Data: data, + } + + payload, err := json.Marshal(event) + if err != nil { + return fmt.Errorf("failed to marshal webhook payload: %w", err) + } + + req, err := http.NewRequest("POST", wh.URL, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("failed to create webhook request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Webhook-ID", wh.ID) + req.Header.Set("X-Webhook-Signature", ComputeSignature(wh.Secret, payload)) + req.Header.Set("X-Webhook-Timestamp", event.Timestamp) + + for k, v := range wh.Headers { + req.Header.Set(k, v) + } + + client := &http.Client{Timeout: time.Duration(wh.TimeoutSec) * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to deliver webhook: %w", err) + } + defer resp.Body.Close() + + return nil +} + +// VerifySignature verifies a webhook payload signature. +func VerifySignature(secret string, payload []byte, signature string) bool { + expected := ComputeSignature(secret, payload) + return hmac.Equal([]byte(signature), []byte(expected)) +} + +// GetActiveWebhooksForEvent returns all active webhooks that listen to a given event. +func (ws *WebhookStore) GetActiveWebhooksForEvent(eventType EventType) ([]Webhook, error) { + webhooks, err := ws.ListWebhooks() + if err != nil { + return nil, err + } + + var active []Webhook + for _, wh := range webhooks { + if !wh.Active { + continue + } + for _, e := range wh.Events { + if e == string(eventType) { + active = append(active, wh) + break + } + } + } + + return active, nil +} + +func (ws *WebhookStore) loadWebhooks() ([]Webhook, error) { + data, err := os.ReadFile(ws.webhooksFile) + if err != nil { + if os.IsNotExist(err) { + return []Webhook{}, nil + } + return nil, fmt.Errorf("failed to read webhooks: %w", err) + } + + var webhooks []Webhook + if err := json.Unmarshal(data, &webhooks); err != nil { + return nil, fmt.Errorf("failed to parse webhooks: %w", err) + } + + return webhooks, nil +} + +func (ws *WebhookStore) saveWebhooks(webhooks []Webhook) error { + if err := os.MkdirAll(ws.configDir, 0700); err != nil { + return fmt.Errorf("failed to create config dir: %w", err) + } + + data, err := json.MarshalIndent(webhooks, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal webhooks: %w", err) + } + + return os.WriteFile(ws.webhooksFile, data, 0600) +} + +// ComputeSignature computes the HMAC-SHA256 signature for a webhook payload. +func ComputeSignature(secret string, payload []byte) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(payload) + return hex.EncodeToString(mac.Sum(nil)) +} + +func generateID() string { + return fmt.Sprintf("wh_%d", time.Now().UnixNano()) +} + +func generateSecret() string { + b := make([]byte, 32) + if _, err := randRead(b); err != nil { + return fmt.Sprintf("secret-%d", time.Now().UnixNano()) + } + return hex.EncodeToString(b) +} + +func randRead(b []byte) (int, error) { + return rand.Read(b) +} + +func eventTypeStrings(events []EventType) []string { + strs := make([]string, len(events)) + for i, e := range events { + strs[i] = string(e) + } + return strs +}