Compare commits

...

10 Commits

Author SHA1 Message Date
3e9edc2ae1 Fix Go version matrix and coverage calculation portability
Some checks are pending
CI / build (1.23.x) (push) Waiting to run
CI / security-scan (push) Waiting to run
P0: Update Go version matrix from [1.21.x, 1.22.x] to [1.23.x] to match go.mod (go 1.23.0)
P0: Update security-scan Go version from 1.21.x to 1.23.x
P1: Replace grep -oP with portable awk for coverage threshold calculation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 00:40:43 -04:00
d28834831a FRE-4764: Fix response body leaks, race conditions, and thread-safety issues
- P1.2: Close lastResp.Body on context cancellation during retry backoff
- P1.1: Close original response body after io.ReadAll on error paths
  to return TCP connections to the pool
- P2.3: Close response body in doSingleRequest on error paths (http.Client.Do
  can return non-nil resp with non-nil err)
- P2.3: Defensive body close on auth refresh retry failure
- P2: Simplify shouldRetryError with explicit type checks
- P2: RateLimiter in-place filtering to reduce GC pressure
- P3.6: Replace math/rand with crypto/rand for thread-safe jitter
- P3.7: Add missing error code constants (SessionExpired, TokenExpired,
  QuotaExceeded, AccountSuspended)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 00:40:24 -04:00
2cffa1ead7 FRE-4680: Implement Milestone 2 advanced features
- Draft auto-save with configurable interval, status, and manual trigger
- Email template system (save, list, use, delete templates)
- Export messages to JSON, MBOX, or EML format
- Import messages from JSON files
- Register new commands (thread, bulk, export, import, autosave, template)
- Update README.md with documentation for all new commands

Build and tests pass clean.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 00:40:24 -04:00
bf26cd3ed6 feat: implement Milestone 3 integration points
Add comprehensive integration capabilities to Pop CLI:

- Multi-account support with named profiles
- Webhook management with signature verification
- External PGP key management (import/export/encrypt/decrypt/sign/verify)
- CLI plugin system for extensibility
- Complete documentation in README.md

All compilation errors fixed and build verified CLEAN.

Security review delegated to FRE-5202.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 00:40:24 -04:00
CTO
e7e77fcc20 FRE-5160: Recover missing next step FRE-4764
Resolved circular block by marking recovery issue as done.

The circular dependency was:
- FRE-4764 blocked by FRE-5160 (recovery issue)
- FRE-5160 existed because FRE-4764 had no proper disposition

Resolution:
- FRE-5160 marked as done with explanation
- FRE-4764 now unblocked from the circular dependency
- Original issue FRE-4764 remains for assignee (Senior Engineer) to address
2026-05-14 00:40:24 -04:00
6663ebc778 FRE-4762: Update API endpoints to match ProtonMail v4 contract
- Change all paths from /api/messages to /mail/v4/messages
- Update HTTP methods: GET for reads, PUT for updates, DELETE for deletes
- Fix response structures to match official API format
- Add X-HTTP-Method-Override header for list operations

Changes align with go-proton-api reference implementation.
2026-05-14 00:40:24 -04:00
c8ffe76688 FRE-4763: Fix Clone() context argument and apply Code Reviewer P0-P3 fixes
- P0: Update auth header after token refresh via GetSession() + SetAuthHeader()
- P2: Unconditional req.WithContext(ctx) instead of fragile context.Background() check
- Fix: req.Clone(ctx) takes context.Context, not *http.Request (req.WithContext(ctx))
- Remove unused checkAuthenticated() and NewRequestWithContext() helpers
2026-05-14 00:40:24 -04:00
2b8051efb1 Fix Go version matrix and coverage calculation fragility [FRE-4695]
Some checks failed
CI / build (1.23.x) (push) Has been cancelled
CI / security-scan (push) Has been cancelled
- Update matrix from 1.21.x/1.22.x to 1.23.x to match go.mod
- Replace grep -oP with portable awk for coverage parsing
- Update security-scan job to use 1.23.x

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-10 17:03:30 -04:00
Senior Engineer
5dc4a1b742 Fix FRE-4693 code review findings: 2-arg constructor, 403 error test, error content check
Some checks failed
CI / build (1.21.x) (push) Has been cancelled
CI / build (1.22.x) (push) Has been cancelled
CI / security-scan (push) Has been cancelled
- Pass nil refresher to NewProtonMailClient at all 5 call sites
- Change TestListMessages_APIError from 401 to 403 (avoids refresh interception)
- Add error content assertion to TestGetMessage_NotFound
2026-05-10 09:38:21 -04:00
691a2acdad feat: implement automatic auth token refresh on 401 with context support (FRE-4763)
- Add SessionRefresher interface for token refresh abstraction
- Update ProtonMailClient to auto-refresh on 401 responses
- Add DoWithContext method for context-aware HTTP requests
- Update SessionManager with RefreshTokenWithContext method
- Update LoginWithCredentials and LoginInteractive to accept context
- Add checkAuthenticatedWithManager helper for commands needing session manager
- All API methods now support proper cancellation via context.Context

Files changed:
- internal/api/client.go - Auto-refresh on 401, context support
- internal/auth/session.go - Context-aware refresh and login methods
- internal/auth/interface.go - SessionRefresher interface
- cmd/mail.go, cmd/draft.go, cmd/folders.go - Updated to use new helpers
- cmd/auth.go - Context support for login commands

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-09 21:46:03 -04:00
26 changed files with 5327 additions and 224 deletions

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
go-version: [1.21.x, 1.22.x] go-version: [1.23.x]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -37,22 +37,10 @@ jobs:
- name: Build - name: Build
run: go build -v ./... run: go build -v ./...
- name: Test with coverage - name: Test with coverage and enforce threshold
run: go test -v -race -coverprofile=coverage.out -covermode=atomic ./...
- name: Calculate coverage
run: | run: |
TOTAL=$(go test -cover ./... 2>&1 | grep -oP '\d+\.\d+%$' | head -1 | tr -d '%') go test -v -race -coverprofile=coverage.out -covermode=atomic ./... 2>&1
echo "Coverage: ${TOTAL}" go test -cover ./... 2>&1 | awk '/^ok/ {split($NF,a,"%"); if (a[1]+0 < 80) {print "Coverage " a[1] "% is below 80% threshold"; exit 1} else print "Coverage " a[1] "% meets 80% threshold"}'
if [ -z "$TOTAL" ]; then
echo "No coverage data found"
exit 1
fi
if (( $(echo "$TOTAL < 80" | bc -l) )); then
echo "Coverage ${TOTAL}% is below 80% threshold"
exit 1
fi
echo "Coverage ${TOTAL}% meets 80% threshold"
- name: Upload coverage report - name: Upload coverage report
uses: codecov/codecov-action@v4 uses: codecov/codecov-action@v4
@@ -74,7 +62,7 @@ jobs:
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: 1.21.x go-version: 1.23.x
- name: Run GoSec - name: Run GoSec
uses: securego/gosec@v2 uses: securego/gosec@v2

278
README.md
View File

@@ -8,6 +8,14 @@ A ProtonMail CLI tool written in Go, similar to gog.
- **Session Management**: Secure token storage in `~/.config/pop/` - **Session Management**: Secure token storage in `~/.config/pop/`
- **ProtonMail API Client**: REST client with rate limiting and error handling - **ProtonMail API Client**: REST client with rate limiting and error handling
- **PGP Encryption**: Full support for ProtonMail's PGP encryption via gopenpgp v2 - **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
- **Email Threading**: View and reply to conversation threads
- **Bulk Operations**: Delete, trash, star, and mark read/unread multiple messages at once
- **Export/Import**: Export messages to JSON, MBOX, or EML format; import from JSON
- **Draft Auto-Save**: Automatic draft saving with configurable interval and email templates
## Installation ## Installation
@@ -23,6 +31,8 @@ make install
## Usage ## Usage
### Authentication
```bash ```bash
# Initialize login (interactive mode with masked password prompt) # Initialize login (interactive mode with masked password prompt)
pop login pop login
@@ -34,20 +44,208 @@ pop session
pop logout 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
```
### Email Threading
```bash
# List conversation threads
pop thread list
# View a full conversation thread
pop thread show <conversation-id>
# Reply to a conversation
pop thread reply <conversation-id> --body "Your reply here"
pop thread reply <conversation-id> --body-file reply.txt
```
### Bulk Operations
```bash
# Delete multiple messages
pop bulk delete --ids "id1,id2,id3"
pop bulk delete --ids-file ids.txt
# Move multiple messages to trash
pop bulk trash --ids "id1,id2,id3"
# Star multiple messages
pop bulk star --ids "id1,id2,id3"
pop bulk unstar --ids "id1,id2,id3"
# Mark messages as read/unread
pop bulk mark-read --ids "id1,id2,id3"
pop bulk mark-unread --ids "id1,id2,id3"
```
### Export and Import
```bash
# Export specific messages
pop export messages --ids "id1,id2,id3" --output messages.json
pop export messages --ids-file ids.txt --output messages.json
# Export messages by search
pop export messages --search "important" --output search-results.json
# Export all messages from a folder
pop export folder --folder inbox --output inbox-backup.json
pop export folder --folder sent --format mbox --output sent.mbox
# Export a conversation
pop export conversation <conversation-id> --output conversation.json
# Import messages from JSON
pop import --file messages.json
```
### Draft Auto-Save
```bash
# Enable auto-save with default 30-second interval
pop draft autosave enable
# Enable with custom interval
pop draft autosave enable --interval 60
# Disable auto-save
pop draft autosave disable
# Check auto-save status
pop draft autosave status
# Manually trigger auto-save
pop draft autosave run
# Manage email templates
pop draft template save newsletter --subject "Monthly Update" --body "Hello..."
pop draft template list
pop draft template use newsletter --to recipient@example.com
pop draft template delete newsletter
```
## Project Structure ## Project Structure
``` ```
pop/ pop/
├── cmd/ ├── cmd/
│ ├── root.go # CLI root command │ ├── 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 # Conversation threading
│ ├── bulk.go # Bulk operations
│ ├── export.go # Export/import functionality
│ └── draft_autosave.go # Draft auto-save and templates
├── internal/ ├── internal/
│ ├── auth/ # Session management │ ├── auth/ # Session management
│ │ └── session.go │ │ └── session.go
│ ├── config/ # Configuration handling │ ├── config/ # Configuration handling
│ │ └── config.go │ │ └── config.go
── api/ # ProtonMail API client ── api/ # ProtonMail API client
└── client.go └── 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/ ├── .github/
│ └── workflows/ │ └── workflows/
│ └── ci.yml # CI/CD pipeline │ └── ci.yml # CI/CD pipeline
@@ -83,6 +281,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 ## Development
```bash ```bash

210
cmd/accounts.go Normal file
View File

@@ -0,0 +1,210 @@
package cmd
import (
"fmt"
"os"
"text/tabwriter"
"github.com/frenocorp/pop/internal/accounts"
"github.com/spf13/cobra"
)
func accountsCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "accounts",
Short: "Manage multiple ProtonMail accounts",
Long: `Add, list, switch, and remove named ProtonMail account profiles.`,
}
cmd.AddCommand(accountsListCmd())
cmd.AddCommand(accountsAddCmd())
cmd.AddCommand(accountsRemoveCmd())
cmd.AddCommand(accountsDefaultCmd())
cmd.AddCommand(accountsShowCmd())
return cmd
}
func accountsListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all configured accounts",
Long: `Show all saved ProtonMail account profiles.`,
RunE: func(cmd *cobra.Command, args []string) error {
store, err := accounts.NewAccountsStore()
if err != nil {
return fmt.Errorf("failed to create accounts store: %w", err)
}
accts, err := store.LoadAccounts()
if err != nil {
return fmt.Errorf("failed to load accounts: %w", err)
}
if len(accts) == 0 {
fmt.Println("No accounts configured. Use 'pop accounts add' to create one.")
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "Name\tEmail\tDefault\tAPI Base\tCreated")
fmt.Fprintln(w, "----\t-----\t-------\t--------\t-------")
for _, acc := range accts {
def := "-"
if acc.Default {
def = "*"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
acc.Name, acc.Email, def, acc.APIBaseURL, acc.CreatedAt)
}
return w.Flush()
},
}
}
func accountsAddCmd() *cobra.Command {
var name, email, apiURL string
var isDefault bool
cmd := &cobra.Command{
Use: "add <name>",
Short: "Add a new account profile",
Long: `Add a named ProtonMail account profile for multi-account support.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
accountName := name
if len(args) > 0 && args[0] != "" {
accountName = args[0]
}
if accountName == "" {
return fmt.Errorf("account name is required")
}
if email == "" {
return fmt.Errorf("email is required (--email)")
}
store, err := accounts.NewAccountsStore()
if err != nil {
return fmt.Errorf("failed to create accounts store: %w", err)
}
if err := store.AddAccount(accountName, email, apiURL, isDefault); err != nil {
return fmt.Errorf("failed to add account: %w", err)
}
fmt.Printf("Added account: %s (%s)\n", accountName, email)
if isDefault {
fmt.Println("Set as default account")
}
return nil
},
}
cmd.Flags().StringVar(&name, "name", "", "Account name (or pass as positional argument)")
cmd.Flags().StringVarP(&email, "email", "e", "", "ProtonMail email address")
cmd.Flags().StringVar(&apiURL, "api-url", "", "Custom API base URL")
cmd.Flags().BoolVarP(&isDefault, "default", "d", false, "Set as default account")
_ = cmd.MarkFlagRequired("email")
return cmd
}
func accountsRemoveCmd() *cobra.Command {
return &cobra.Command{
Use: "remove <name>",
Short: "Remove an account profile",
Long: `Remove a named ProtonMail account profile.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
accountName := args[0]
store, err := accounts.NewAccountsStore()
if err != nil {
return fmt.Errorf("failed to create accounts store: %w", err)
}
if err := store.RemoveAccount(accountName); err != nil {
return fmt.Errorf("failed to remove account: %w", err)
}
fmt.Printf("Removed account: %s\n", accountName)
return nil
},
}
}
func accountsDefaultCmd() *cobra.Command {
var setName string
cmd := &cobra.Command{
Use: "default [name]",
Short: "Get or set the default account",
Long: `Show the current default account, or set a new default.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
store, err := accounts.NewAccountsStore()
if err != nil {
return fmt.Errorf("failed to create accounts store: %w", err)
}
if setName == "" && len(args) > 0 {
setName = args[0]
}
if setName != "" {
if err := store.SetDefaultAccount(setName); err != nil {
return fmt.Errorf("failed to set default account: %w", err)
}
fmt.Printf("Set default account to: %s\n", setName)
return nil
}
acct, err := store.GetAccount("")
if err != nil {
return fmt.Errorf("no default account: %w", err)
}
fmt.Printf("Default account: %s (%s)\n", acct.Name, acct.Email)
return nil
},
}
cmd.Flags().StringVar(&setName, "set", "", "Set default account to this name")
return cmd
}
func accountsShowCmd() *cobra.Command {
return &cobra.Command{
Use: "show <name>",
Short: "Show account details",
Long: `Display details for a specific account profile.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
accountName := args[0]
store, err := accounts.NewAccountsStore()
if err != nil {
return fmt.Errorf("failed to create accounts store: %w", err)
}
acct, err := store.GetAccount(accountName)
if err != nil {
return fmt.Errorf("failed to get account: %w", err)
}
fmt.Printf("Name: %s\n", acct.Name)
fmt.Printf("Email: %s\n", acct.Email)
fmt.Printf("UID: %s\n", acct.UID)
fmt.Printf("API Base: %s\n", acct.APIBaseURL)
fmt.Printf("Default: %t\n", acct.Default)
fmt.Printf("Created: %s\n", acct.CreatedAt)
if acct.LastUsedAt != "" {
fmt.Printf("Last Used: %s\n", acct.LastUsedAt)
}
return nil
},
}
}

View File

@@ -1,6 +1,7 @@
package cmd package cmd
import ( import (
"context"
"fmt" "fmt"
"os" "os"
@@ -26,7 +27,7 @@ func loginCmd() *cobra.Command {
return fmt.Errorf("failed to create session manager: %w", err) return fmt.Errorf("failed to create session manager: %w", err)
} }
return manager.LoginInteractive(cfg.APIBaseURL) return manager.LoginInteractive(context.Background(), cfg.APIBaseURL)
}, },
} }

365
cmd/bulk.go Normal file
View File

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

View File

@@ -64,12 +64,12 @@ func draftSaveCmd() *cobra.Command {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
session, err := checkAuthenticated() session, sessionMgr, err := checkAuthenticatedWithManager()
if err != nil { if err != nil {
return fmt.Errorf("not authenticated: %w", err) return fmt.Errorf("not authenticated: %w", err)
} }
client := api.NewProtonMailClient(cfg) client := api.NewProtonMailClient(cfg, sessionMgr)
client.SetAuthHeader(session.AccessToken) client.SetAuthHeader(session.AccessToken)
mailClient := internalmail.NewClient(client) mailClient := internalmail.NewClient(client)
@@ -125,12 +125,12 @@ func draftListCmd() *cobra.Command {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
session, err := checkAuthenticated() session, sessionMgr, err := checkAuthenticatedWithManager()
if err != nil { if err != nil {
return fmt.Errorf("not authenticated: %w", err) return fmt.Errorf("not authenticated: %w", err)
} }
client := api.NewProtonMailClient(cfg) client := api.NewProtonMailClient(cfg, sessionMgr)
client.SetAuthHeader(session.AccessToken) client.SetAuthHeader(session.AccessToken)
mailClient := internalmail.NewClient(client) mailClient := internalmail.NewClient(client)
@@ -190,12 +190,12 @@ func draftEditCmd() *cobra.Command {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
session, err := checkAuthenticated() session, sessionMgr, err := checkAuthenticatedWithManager()
if err != nil { if err != nil {
return fmt.Errorf("not authenticated: %w", err) return fmt.Errorf("not authenticated: %w", err)
} }
client := api.NewProtonMailClient(cfg) client := api.NewProtonMailClient(cfg, sessionMgr)
client.SetAuthHeader(session.AccessToken) client.SetAuthHeader(session.AccessToken)
mailClient := internalmail.NewClient(client) mailClient := internalmail.NewClient(client)
@@ -241,12 +241,12 @@ func draftSendCmd() *cobra.Command {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
session, err := checkAuthenticated() session, sessionMgr, err := checkAuthenticatedWithManager()
if err != nil { if err != nil {
return fmt.Errorf("not authenticated: %w", err) return fmt.Errorf("not authenticated: %w", err)
} }
client := api.NewProtonMailClient(cfg) client := api.NewProtonMailClient(cfg, sessionMgr)
client.SetAuthHeader(session.AccessToken) client.SetAuthHeader(session.AccessToken)
mailClient := internalmail.NewClient(client) mailClient := internalmail.NewClient(client)

606
cmd/draft_autosave.go Normal file
View File

@@ -0,0 +1,606 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/frenocorp/pop/internal/api"
"github.com/frenocorp/pop/internal/config"
internalmail "github.com/frenocorp/pop/internal/mail"
"github.com/spf13/cobra"
)
func draftAutoSaveCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "autosave",
Short: "Configure draft auto-save",
Long: `Enable or configure automatic draft saving. Set interval, view status, and manage auto-save settings.`,
}
cmd.AddCommand(autosaveEnableCmd())
cmd.AddCommand(autosaveDisableCmd())
cmd.AddCommand(autosaveStatusCmd())
cmd.AddCommand(autosaveConfigCmd())
cmd.AddCommand(autosaveRunCmd())
return cmd
}
func autosaveEnableCmd() *cobra.Command {
var interval int
cmd := &cobra.Command{
Use: "enable",
Short: "Enable draft auto-save",
Long: `Enable automatic saving of draft messages at a configured interval.`,
RunE: func(cmd *cobra.Command, args []string) error {
if interval < 10 {
interval = 30
}
if interval > 3600 {
interval = 3600
}
cfgMgr := config.NewConfigManager()
autoSaveConfig, err := loadAutoSaveConfig(cfgMgr)
if err != nil {
return fmt.Errorf("failed to load auto-save config: %w", err)
}
autoSaveConfig.Enabled = true
autoSaveConfig.Interval = interval
if err := saveAutoSaveConfig(cfgMgr, autoSaveConfig); err != nil {
return fmt.Errorf("failed to save auto-save config: %w", err)
}
fmt.Printf("Draft auto-save enabled (interval: %d seconds)\n", interval)
return nil
},
}
cmd.Flags().IntVarP(&interval, "interval", "i", 30, "Auto-save interval in seconds (10-3600, default: 30)")
return cmd
}
func autosaveDisableCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "disable",
Short: "Disable draft auto-save",
Long: `Disable automatic saving of draft messages.`,
RunE: func(cmd *cobra.Command, args []string) error {
cfgMgr := config.NewConfigManager()
autoSaveConfig, err := loadAutoSaveConfig(cfgMgr)
if err != nil {
return fmt.Errorf("failed to load auto-save config: %w", err)
}
autoSaveConfig.Enabled = false
if err := saveAutoSaveConfig(cfgMgr, autoSaveConfig); err != nil {
return fmt.Errorf("failed to save auto-save config: %w", err)
}
fmt.Println("Draft auto-save disabled")
return nil
},
}
return cmd
}
func autosaveStatusCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "status",
Short: "Show auto-save status",
Long: `Display the current auto-save configuration and last save time.`,
RunE: func(cmd *cobra.Command, args []string) error {
cfgMgr := config.NewConfigManager()
autoSaveConfig, err := loadAutoSaveConfig(cfgMgr)
if err != nil {
return fmt.Errorf("failed to load auto-save config: %w", err)
}
fmt.Printf("Enabled: %t\n", autoSaveConfig.Enabled)
fmt.Printf("Interval: %d seconds\n", autoSaveConfig.Interval)
if autoSaveConfig.LastSaved > 0 {
lastSaved := time.Unix(autoSaveConfig.LastSaved, 0)
elapsed := time.Since(lastSaved)
fmt.Printf("Last Save: %s (%s ago)\n", lastSaved.Format("2006-01-02 15:04:05"), elapsed.Round(time.Second).String())
} else {
fmt.Println("Last Save: Never")
}
return nil
},
}
return cmd
}
func autosaveConfigCmd() *cobra.Command {
var interval int
cmd := &cobra.Command{
Use: "config",
Short: "Configure auto-save settings",
Long: `Update auto-save interval and settings. Does not enable/disable auto-save.`,
RunE: func(cmd *cobra.Command, args []string) error {
if interval < 10 {
interval = 30
}
if interval > 3600 {
interval = 3600
}
cfgMgr := config.NewConfigManager()
autoSaveConfig, err := loadAutoSaveConfig(cfgMgr)
if err != nil {
return fmt.Errorf("failed to load auto-save config: %w", err)
}
autoSaveConfig.Interval = interval
if err := saveAutoSaveConfig(cfgMgr, autoSaveConfig); err != nil {
return fmt.Errorf("failed to save auto-save config: %w", err)
}
fmt.Printf("Auto-save interval updated to %d seconds\n", interval)
return nil
},
}
cmd.Flags().IntVarP(&interval, "interval", "i", 0, "New auto-save interval in seconds (10-3600)")
_ = cmd.MarkFlagRequired("interval")
return cmd
}
func autosaveRunCmd() *cobra.Command {
var draftID string
cmd := &cobra.Command{
Use: "run",
Short: "Manually trigger auto-save",
Long: `Manually trigger the auto-save process for the current draft or a specific draft.`,
RunE: func(cmd *cobra.Command, args []string) error {
cfgMgr := config.NewConfigManager()
autoSaveConfig, err := loadAutoSaveConfig(cfgMgr)
if err != nil {
return fmt.Errorf("failed to load auto-save config: %w", err)
}
if !autoSaveConfig.Enabled {
fmt.Println("Auto-save is disabled. Enable with 'pop draft autosave enable'")
return nil
}
session, sessionMgr, err := checkAuthenticatedWithManager()
if err != nil {
return err
}
cfg, err := cfgMgr.Load()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
client := api.NewProtonMailClient(cfg, sessionMgr)
client.SetAuthHeader(session.AccessToken)
mailClient := internalmail.NewClient(client)
localDrafts, err := loadLocalDrafts(cfgMgr)
if err != nil {
return fmt.Errorf("failed to load local drafts: %w", err)
}
if draftID == "" {
draftID = getLastEditedDraft(localDrafts)
if draftID == "" {
fmt.Println("No active draft to save")
return nil
}
}
localDraft, found := findLocalDraft(localDrafts, draftID)
if !found {
return fmt.Errorf("draft not found: %s", draftID)
}
draft := internalmail.Draft{
MessageID: localDraft.MessageID,
To: localDraft.To,
CC: localDraft.CC,
BCC: localDraft.BCC,
Subject: localDraft.Subject,
Body: localDraft.Body,
}
var errResult error
if localDraft.MessageID == "" {
id, err := mailClient.SaveDraft(draft, session.MailPassphrase)
if err != nil {
errResult = fmt.Errorf("failed to save draft: %w", err)
} else {
localDraft.MessageID = id
fmt.Printf("Draft saved with ID: %s\n", id)
}
} else {
errResult = mailClient.UpdateDraft(localDraft.MessageID, draft, session.MailPassphrase)
if errResult != nil {
errResult = fmt.Errorf("failed to update draft: %w", errResult)
} else {
fmt.Printf("Draft updated: %s\n", localDraft.MessageID)
}
}
if errResult == nil {
localDraft.LastEdited = time.Now().Unix()
autoSaveConfig.LastSaved = time.Now().Unix()
saveLocalDrafts(cfgMgr, localDrafts)
saveAutoSaveConfig(cfgMgr, autoSaveConfig)
}
return errResult
},
}
cmd.Flags().StringVarP(&draftID, "draft", "d", "", "Specific draft ID to save (default: most recently edited)")
return cmd
}
func draftTemplateCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "template",
Short: "Manage email templates",
Long: `Save and reuse email templates for quick draft creation.`,
}
cmd.AddCommand(templateSaveCmd())
cmd.AddCommand(templateListCmd())
cmd.AddCommand(templateUseCmd())
cmd.AddCommand(templateDeleteCmd())
return cmd
}
func templateSaveCmd() *cobra.Command {
var name, subject, body, bodyFile string
cmd := &cobra.Command{
Use: "save <name>",
Short: "Save an email template",
Long: `Save a subject and body as a reusable email template.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
templateName := name
if len(args) > 0 && args[0] != "" {
templateName = args[0]
}
if templateName == "" {
return fmt.Errorf("template name is required")
}
msgBody := body
if bodyFile != "" {
data, err := os.ReadFile(bodyFile)
if err != nil {
return fmt.Errorf("failed to read body file: %w", err)
}
msgBody = string(data)
}
cfgMgr := config.NewConfigManager()
templates, err := loadTemplates(cfgMgr)
if err != nil {
return fmt.Errorf("failed to load templates: %w", err)
}
templates[templateName] = DraftTemplate{
Name: templateName,
Subject: subject,
Body: msgBody,
SavedAt: time.Now().Format(time.RFC3339),
}
if err := saveTemplates(cfgMgr, templates); err != nil {
return fmt.Errorf("failed to save templates: %w", err)
}
fmt.Printf("Template '%s' saved\n", templateName)
return nil
},
}
cmd.Flags().StringVar(&name, "name", "", "Template name (or pass as positional argument)")
cmd.Flags().StringVarP(&subject, "subject", "s", "", "Template subject")
cmd.Flags().StringVarP(&bodyFile, "body-file", "f", "", "File containing template body")
cmd.Flags().StringVar(&body, "body", "", "Inline template body")
return cmd
}
func templateListCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List email templates",
Long: `List all saved email templates.`,
RunE: func(cmd *cobra.Command, args []string) error {
cfgMgr := config.NewConfigManager()
templates, err := loadTemplates(cfgMgr)
if err != nil {
return fmt.Errorf("failed to load templates: %w", err)
}
if len(templates) == 0 {
fmt.Println("No templates saved")
return nil
}
for _, t := range templates {
subject := t.Subject
if len(subject) > 50 {
subject = subject[:47] + "..."
}
fmt.Printf(" %-20s %-50s %s\n", t.Name, subject, t.SavedAt)
}
return nil
},
}
return cmd
}
func templateUseCmd() *cobra.Command {
var to string
cmd := &cobra.Command{
Use: "use <name>",
Short: "Use an email template",
Long: `Load an email template and create a draft from it.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
templateName := args[0]
cfgMgr := config.NewConfigManager()
templates, err := loadTemplates(cfgMgr)
if err != nil {
return fmt.Errorf("failed to load templates: %w", err)
}
template, found := templates[templateName]
if !found {
return fmt.Errorf("template not found: %s", templateName)
}
var recipients []internalmail.Recipient
if to != "" {
recipients = parseRecipients(to)
}
session, sessionMgr, err := checkAuthenticatedWithManager()
if err != nil {
return err
}
cfg, err := cfgMgr.Load()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
client := api.NewProtonMailClient(cfg, sessionMgr)
client.SetAuthHeader(session.AccessToken)
mailClient := internalmail.NewClient(client)
draft := internalmail.Draft{
To: recipients,
Subject: template.Subject,
Body: template.Body,
}
id, err := mailClient.SaveDraft(draft, session.MailPassphrase)
if err != nil {
return fmt.Errorf("failed to create draft from template: %w", err)
}
fmt.Printf("Draft created from template '%s' with ID: %s\n", templateName, id)
return nil
},
}
cmd.Flags().StringVarP(&to, "to", "t", "", "Recipient addresses (comma-separated)")
return cmd
}
func templateDeleteCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "delete <name>",
Short: "Delete an email template",
Long: `Delete a saved email template.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
templateName := args[0]
cfgMgr := config.NewConfigManager()
templates, err := loadTemplates(cfgMgr)
if err != nil {
return fmt.Errorf("failed to load templates: %w", err)
}
if _, found := templates[templateName]; !found {
return fmt.Errorf("template not found: %s", templateName)
}
delete(templates, templateName)
if err := saveTemplates(cfgMgr, templates); err != nil {
return fmt.Errorf("failed to save templates: %w", err)
}
fmt.Printf("Template '%s' deleted\n", templateName)
return nil
},
}
return cmd
}
type DraftTemplate struct {
Name string `json:"name"`
Subject string `json:"subject"`
Body string `json:"body"`
SavedAt string `json:"saved_at"`
}
type LocalDraft struct {
MessageID string `json:"message_id"`
To []internalmail.Recipient `json:"to"`
CC []internalmail.Recipient `json:"cc,omitempty"`
BCC []internalmail.Recipient `json:"bcc,omitempty"`
Subject string `json:"subject"`
Body string `json:"body"`
LastEdited int64 `json:"last_edited"`
}
func getAutoSaveConfigPath(cfgMgr *config.ConfigManager) string {
return filepath.Join(cfgMgr.ConfigDir(), "autosave.json")
}
func loadAutoSaveConfig(cfgMgr *config.ConfigManager) (*internalmail.DraftAutoSaveConfig, error) {
path := getAutoSaveConfigPath(cfgMgr)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &internalmail.DraftAutoSaveConfig{
Enabled: false,
Interval: 30,
}, nil
}
return nil, err
}
var config internalmail.DraftAutoSaveConfig
if err := json.Unmarshal(data, &config); err != nil {
return nil, err
}
return &config, nil
}
func saveAutoSaveConfig(cfgMgr *config.ConfigManager, autoSaveConfig *internalmail.DraftAutoSaveConfig) error {
path := getAutoSaveConfigPath(cfgMgr)
data, err := json.MarshalIndent(autoSaveConfig, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(cfgMgr.ConfigDir(), 0700); err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
func getLocalDraftsPath(cfgMgr *config.ConfigManager) string {
return filepath.Join(cfgMgr.ConfigDir(), "local-drafts.json")
}
func loadLocalDrafts(cfgMgr *config.ConfigManager) ([]LocalDraft, error) {
path := getLocalDraftsPath(cfgMgr)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return []LocalDraft{}, nil
}
return nil, err
}
var drafts []LocalDraft
if err := json.Unmarshal(data, &drafts); err != nil {
return nil, err
}
return drafts, nil
}
func saveLocalDrafts(cfgMgr *config.ConfigManager, drafts []LocalDraft) error {
path := getLocalDraftsPath(cfgMgr)
data, err := json.MarshalIndent(drafts, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(cfgMgr.ConfigDir(), 0700); err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
func getLastEditedDraft(drafts []LocalDraft) string {
var lastID string
var lastTime int64
for _, d := range drafts {
if d.LastEdited > lastTime {
lastTime = d.LastEdited
lastID = d.MessageID
if lastID == "" {
lastID = fmt.Sprintf("local-%d", d.LastEdited)
}
}
}
return lastID
}
func findLocalDraft(drafts []LocalDraft, id string) (LocalDraft, bool) {
for _, d := range drafts {
if d.MessageID == id {
return d, true
}
}
return LocalDraft{}, false
}
func getTemplatesPath(cfgMgr *config.ConfigManager) string {
return filepath.Join(cfgMgr.ConfigDir(), "templates.json")
}
func loadTemplates(cfgMgr *config.ConfigManager) (map[string]DraftTemplate, error) {
path := getTemplatesPath(cfgMgr)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return map[string]DraftTemplate{}, nil
}
return nil, err
}
var templates map[string]DraftTemplate
if err := json.Unmarshal(data, &templates); err != nil {
return nil, err
}
return templates, nil
}
func saveTemplates(cfgMgr *config.ConfigManager, templates map[string]DraftTemplate) error {
path := getTemplatesPath(cfgMgr)
data, err := json.MarshalIndent(templates, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(cfgMgr.ConfigDir(), 0700); err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}

446
cmd/export.go Normal file
View File

@@ -0,0 +1,446 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/frenocorp/pop/internal/api"
"github.com/frenocorp/pop/internal/config"
internalmail "github.com/frenocorp/pop/internal/mail"
"github.com/spf13/cobra"
)
func exportCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "export",
Short: "Export messages",
Long: `Export messages to JSON, MBOX, or EML format for backup or migration.`,
}
cmd.AddCommand(exportMessagesCmd())
cmd.AddCommand(exportConversationCmd())
cmd.AddCommand(exportFolderCmd())
return cmd
}
func importCmd() *cobra.Command {
var filePath, format string
cmd := &cobra.Command{
Use: "import",
Short: "Import messages",
Long: `Import messages from JSON files into ProtonMail.`,
RunE: func(cmd *cobra.Command, args []string) error {
if filePath == "" {
return fmt.Errorf("file path is required (--file)")
}
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)
exportFormat := internalmail.ExportFormatJSON
if format == "json" {
exportFormat = internalmail.ExportFormatJSON
}
req := internalmail.ImportRequest{
FilePath: filePath,
Format: exportFormat,
Passphrase: session.MailPassphrase,
}
result, err := mailClient.ImportMessages(req)
if err != nil {
return fmt.Errorf("failed to import messages: %w", err)
}
fmt.Printf("Imported %d/%d messages\n", result.ImportedCount, 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(&filePath, "file", "f", "", "Path to JSON file containing messages to import")
cmd.Flags().StringVarP(&format, "format", "F", "json", "Import format (json)")
return cmd
}
func exportMessagesCmd() *cobra.Command {
var ids, idsFile, output, format, search string
cmd := &cobra.Command{
Use: "messages",
Short: "Export specific messages",
Long: `Export specific messages by ID or search query. Supports JSON, MBOX, and EML formats.`,
RunE: func(cmd *cobra.Command, args []string) error {
messageIDs := collectMessageIDs(ids, idsFile)
if output == "" {
output = "pop-export-" + time.Now().Format("2006-01-02") + ".json"
}
exportFormat := internalmail.ExportFormatJSON
switch format {
case "mbox":
exportFormat = internalmail.ExportFormatMBOX
case "eml":
exportFormat = internalmail.ExportFormatEMail
}
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.ExportRequest{
MessageIDs: messageIDs,
Format: exportFormat,
Search: search,
Passphrase: session.MailPassphrase,
}
exported, err := mailClient.ExportMessages(req)
if err != nil {
return fmt.Errorf("failed to export messages: %w", err)
}
if len(exported) == 0 {
fmt.Println("No messages to export")
return nil
}
if err := writeExport(exported, output, exportFormat); err != nil {
return fmt.Errorf("failed to write export file: %w", err)
}
fmt.Printf("Exported %d message(s) to %s\n", len(exported), output)
return nil
},
}
cmd.Flags().StringVarP(&ids, "ids", "i", "", "Comma-separated message IDs to export")
cmd.Flags().StringVarP(&idsFile, "ids-file", "f", "", "File with one message ID per line")
cmd.Flags().StringVarP(&output, "output", "o", "", "Output file path (default: pop-export-YYYY-MM-DD.json)")
cmd.Flags().StringVarP(&format, "format", "F", "json", "Export format: json, mbox, eml")
cmd.Flags().StringVarP(&search, "search", "s", "", "Search query to find messages to export")
return cmd
}
func exportConversationCmd() *cobra.Command {
var output, format string
cmd := &cobra.Command{
Use: "conversation <conversation-id>",
Short: "Export a conversation thread",
Long: `Export all messages in a conversation thread. Supports JSON, MBOX, and EML formats.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
convID := args[0]
if output == "" {
output = "pop-conversation-" + convID + ".json"
}
exportFormat := internalmail.ExportFormatJSON
switch format {
case "mbox":
exportFormat = internalmail.ExportFormatMBOX
case "eml":
exportFormat = internalmail.ExportFormatEMail
}
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)
}
exported := make([]internalmail.ExportedMessage, 0, len(conv.Messages))
for _, msg := range conv.Messages {
exp := internalmail.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)
}
if len(exported) == 0 {
fmt.Println("No messages in conversation")
return nil
}
if err := writeExport(exported, output, exportFormat); err != nil {
return fmt.Errorf("failed to write export file: %w", err)
}
fmt.Printf("Exported %d message(s) from conversation %s to %s\n", len(exported), convID, output)
return nil
},
}
cmd.Flags().StringVarP(&output, "output", "o", "", "Output file path")
cmd.Flags().StringVarP(&format, "format", "F", "json", "Export format: json, mbox, eml")
return cmd
}
func exportFolderCmd() *cobra.Command {
var folder, output, format string
var since int64
cmd := &cobra.Command{
Use: "folder",
Short: "Export all messages in a folder",
Long: `Export all messages from a specific folder. Supports JSON, MBOX, and EML formats.`,
RunE: func(cmd *cobra.Command, args []string) error {
if output == "" {
output = "pop-folder-" + folder + "-" + time.Now().Format("2006-01-02") + ".json"
}
exportFormat := internalmail.ExportFormatJSON
switch format {
case "mbox":
exportFormat = internalmail.ExportFormatMBOX
case "eml":
exportFormat = internalmail.ExportFormatEMail
}
folderVal := internalmail.FolderInbox
switch folder {
case "inbox":
folderVal = internalmail.FolderInbox
case "sent":
folderVal = internalmail.FolderSent
case "drafts":
folderVal = internalmail.FolderDraft
case "trash":
folderVal = internalmail.FolderTrash
case "spam":
folderVal = internalmail.FolderSpam
default:
return fmt.Errorf("unknown folder: %s (valid: inbox, sent, drafts, trash, spam)", folder)
}
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.ExportRequest{
Folder: folderVal,
Format: exportFormat,
Since: since,
Passphrase: session.MailPassphrase,
}
exported, err := mailClient.ExportMessages(req)
if err != nil {
return fmt.Errorf("failed to export messages: %w", err)
}
if len(exported) == 0 {
fmt.Println("No messages to export")
return nil
}
if err := writeExport(exported, output, exportFormat); err != nil {
return fmt.Errorf("failed to write export file: %w", err)
}
fmt.Printf("Exported %d message(s) from %s folder to %s\n", len(exported), folder, output)
return nil
},
}
cmd.Flags().StringVarP(&folder, "folder", "f", "inbox", "Folder to export (inbox, sent, drafts, trash, spam)")
cmd.Flags().StringVarP(&output, "output", "o", "", "Output file path")
cmd.Flags().StringVarP(&format, "format", "F", "json", "Export format: json, mbox, eml")
cmd.Flags().Int64Var(&since, "since", 0, "Only export messages modified since Unix timestamp")
return cmd
}
func writeExport(messages []internalmail.ExportedMessage, outputPath string, format internalmail.ExportFormat) error {
dir := filepath.Dir(outputPath)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
}
switch format {
case internalmail.ExportFormatJSON:
return writeJSONExport(messages, outputPath)
case internalmail.ExportFormatMBOX:
return writeMBOXExport(messages, outputPath)
case internalmail.ExportFormatEMail:
return writeEMLExport(messages, outputPath)
default:
return writeJSONExport(messages, outputPath)
}
}
func writeJSONExport(messages []internalmail.ExportedMessage, outputPath string) error {
data, err := json.MarshalIndent(messages, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal JSON: %w", err)
}
if err := os.WriteFile(outputPath, data, 0644); err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
return nil
}
func writeMBOXExport(messages []internalmail.ExportedMessage, outputPath string) error {
file, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer file.Close()
for _, msg := range messages {
fromName := msg.From.Address
if msg.From.Name != "" {
fromName = msg.From.Name
}
fmt.Fprintf(file, "From %s %s\n", fromName, msg.Date)
fmt.Fprintf(file, "From: %s\n", msg.From.DisplayName())
toParts := make([]string, len(msg.To))
for i, r := range msg.To {
toParts[i] = r.DisplayName()
}
fmt.Fprintf(file, "To: %s\n", strings.Join(toParts, ", "))
if msg.Subject != "" {
fmt.Fprintf(file, "Subject: %s\n", msg.Subject)
}
fmt.Fprintf(file, "X-Message-ID: %s\n", msg.MessageID)
fmt.Fprintf(file, "X-Starred: %t\n", msg.Starred)
fmt.Fprintf(file, "X-Read: %t\n", msg.Read)
fmt.Fprintln(file)
fmt.Fprintln(file, msg.Body)
fmt.Fprintln(file)
}
return nil
}
func writeEMLExport(messages []internalmail.ExportedMessage, baseOutput string) error {
ext := filepath.Ext(baseOutput)
dir := filepath.Dir(baseOutput)
baseName := strings.TrimSuffix(filepath.Base(baseOutput), ext)
for i, msg := range messages {
var emlPath string
if len(messages) == 1 {
emlPath = baseOutput
if ext == "" {
emlPath = baseOutput + ".eml"
}
} else {
emlPath = filepath.Join(dir, fmt.Sprintf("%s-%03d.eml", baseName, i+1))
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("From: %s\r\n", msg.From.DisplayName()))
toParts := make([]string, len(msg.To))
for j, r := range msg.To {
toParts[j] = r.DisplayName()
}
sb.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(toParts, ", ")))
if msg.Subject != "" {
sb.WriteString(fmt.Sprintf("Subject: %s\r\n", msg.Subject))
}
sb.WriteString(fmt.Sprintf("Date: %s\r\n", msg.Date))
sb.WriteString(fmt.Sprintf("Message-ID: <%s>\r\n", msg.MessageID))
sb.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
sb.WriteString("\r\n")
sb.WriteString(msg.Body)
if err := os.WriteFile(emlPath, []byte(sb.String()), 0644); err != nil {
return fmt.Errorf("failed to write EML file %s: %w", emlPath, err)
}
}
return nil
}

View File

@@ -370,7 +370,7 @@ func newLabelClient() (*labels.Client, error) {
return nil, fmt.Errorf("not authenticated: %w", err) return nil, fmt.Errorf("not authenticated: %w", err)
} }
apiClient := api.NewProtonMailClient(cfg) apiClient := api.NewProtonMailClient(cfg, sessionMgr)
apiClient.SetAuthHeader(session.AccessToken) apiClient.SetAuthHeader(session.AccessToken)
return labels.NewClient(apiClient), nil return labels.NewClient(apiClient), nil

View File

@@ -15,20 +15,20 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
func checkAuthenticated() (*auth.Session, error) { func checkAuthenticatedWithManager() (*auth.Session, *auth.SessionManager, error) {
sessionMgr, err := auth.NewSessionManager() sessionMgr, err := auth.NewSessionManager()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create session manager: %w", err) return nil, nil, fmt.Errorf("failed to create session manager: %w", err)
} }
authenticated, err := sessionMgr.IsAuthenticated() authenticated, err := sessionMgr.IsAuthenticated()
if err != nil || !authenticated { if err != nil || !authenticated {
return nil, fmt.Errorf("not authenticated (run 'pop login' first): %w", err) return nil, nil, fmt.Errorf("not authenticated (run 'pop login' first): %w", err)
} }
session, err := sessionMgr.GetSession() session, err := sessionMgr.GetSession()
if err != nil { if err != nil {
return nil, fmt.Errorf("not authenticated: %w", err) return nil, nil, fmt.Errorf("not authenticated: %w", err)
} }
return session, nil return session, sessionMgr, nil
} }
func mailCmd() *cobra.Command { func mailCmd() *cobra.Command {
@@ -64,12 +64,12 @@ func mailListCmd() *cobra.Command {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
session, err := checkAuthenticated() session, sessionMgr, err := checkAuthenticatedWithManager()
if err != nil { if err != nil {
return err return err
} }
client := api.NewProtonMailClient(cfg) client := api.NewProtonMailClient(cfg, sessionMgr)
client.SetAuthHeader(session.AccessToken) client.SetAuthHeader(session.AccessToken)
mailClient := internalmail.NewClient(client) mailClient := internalmail.NewClient(client)
@@ -158,12 +158,12 @@ func mailReadCmd() *cobra.Command {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
session, err := checkAuthenticated() session, sessionMgr, err := checkAuthenticatedWithManager()
if err != nil { if err != nil {
return err return err
} }
client := api.NewProtonMailClient(cfg) client := api.NewProtonMailClient(cfg, sessionMgr)
client.SetAuthHeader(session.AccessToken) client.SetAuthHeader(session.AccessToken)
mailClient := internalmail.NewClient(client) mailClient := internalmail.NewClient(client)
@@ -221,12 +221,12 @@ func mailSendCmd() *cobra.Command {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
session, err := checkAuthenticated() session, sessionMgr, err := checkAuthenticatedWithManager()
if err != nil { if err != nil {
return err return err
} }
client := api.NewProtonMailClient(cfg) client := api.NewProtonMailClient(cfg, sessionMgr)
client.SetAuthHeader(session.AccessToken) client.SetAuthHeader(session.AccessToken)
mailClient := internalmail.NewClient(client) mailClient := internalmail.NewClient(client)
@@ -276,12 +276,12 @@ func mailDeleteCmd() *cobra.Command {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
session, err := checkAuthenticated() session, sessionMgr, err := checkAuthenticatedWithManager()
if err != nil { if err != nil {
return err return err
} }
client := api.NewProtonMailClient(cfg) client := api.NewProtonMailClient(cfg, sessionMgr)
client.SetAuthHeader(session.AccessToken) client.SetAuthHeader(session.AccessToken)
mailClient := internalmail.NewClient(client) mailClient := internalmail.NewClient(client)
@@ -312,12 +312,12 @@ func mailTrashCmd() *cobra.Command {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
session, err := checkAuthenticated() session, sessionMgr, err := checkAuthenticatedWithManager()
if err != nil { if err != nil {
return err return err
} }
client := api.NewProtonMailClient(cfg) client := api.NewProtonMailClient(cfg, sessionMgr)
client.SetAuthHeader(session.AccessToken) client.SetAuthHeader(session.AccessToken)
mailClient := internalmail.NewClient(client) mailClient := internalmail.NewClient(client)
@@ -456,12 +456,12 @@ func mailSearchCmd() *cobra.Command {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
session, err := checkAuthenticated() session, sessionMgr, err := checkAuthenticatedWithManager()
if err != nil { if err != nil {
return err return err
} }
client := api.NewProtonMailClient(cfg) client := api.NewProtonMailClient(cfg, sessionMgr)
client.SetAuthHeader(session.AccessToken) client.SetAuthHeader(session.AccessToken)
mailClient := internalmail.NewClient(client) mailClient := internalmail.NewClient(client)

299
cmd/pgp.go Normal file
View File

@@ -0,0 +1,299 @@
package cmd
import (
"fmt"
"os"
"github.com/frenocorp/pop/internal/pgp"
"github.com/spf13/cobra"
)
func pgpCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "pgp",
Short: "Manage PGP keys",
Long: `Import, export, list, and manage PGP keys for ProtonMail encryption.`,
}
cmd.AddCommand(pgpListCmd())
cmd.AddCommand(pgpImportCmd())
cmd.AddCommand(pgpExportCmd())
cmd.AddCommand(pgpRemoveCmd())
cmd.AddCommand(pgpEncryptCmd())
cmd.AddCommand(pgpDecryptCmd())
cmd.AddCommand(pgpSignCmd())
cmd.AddCommand(pgpVerifyCmd())
return cmd
}
func pgpListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all imported PGP keys",
RunE: func(cmd *cobra.Command, args []string) error {
store, err := pgp.NewKeyStore()
if err != nil {
return fmt.Errorf("failed to create PGP store: %w", err)
}
keys, err := store.ListKeys()
if err != nil {
return fmt.Errorf("failed to list keys: %w", err)
}
if len(keys) == 0 {
fmt.Println("No PGP keys imported.")
return nil
}
for _, key := range keys {
fmt.Printf("Key ID: %s\n Fingerprint: %s\n Emails: %v\n Trust: %s\n Encrypt: %t Sign: %t\n\n",
key.KeyID, key.Fingerprint, key.Emails, key.TrustLevel, key.CanEncrypt, key.CanSign)
}
return nil
},
}
}
func pgpImportCmd() *cobra.Command {
var trustLevel, filePath string
cmd := &cobra.Command{
Use: "import <file>",
Short: "Import a PGP key from file",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
filePath = args[0]
store, err := pgp.NewKeyStore()
if err != nil {
return fmt.Errorf("failed to create PGP store: %w", err)
}
key, err := store.ImportKeyFromFile(filePath, trustLevel)
if err != nil {
return fmt.Errorf("failed to import key: %w", err)
}
fmt.Printf("Imported key:\n ID: %s\n Fingerprint: %s\n Emails: %v\n",
key.KeyID, key.Fingerprint, key.Emails)
return nil
},
}
cmd.Flags().StringVar(&trustLevel, "trust", "unknown", "Trust level for the key")
return cmd
}
func pgpExportCmd() *cobra.Command {
var outputPath string
cmd := &cobra.Command{
Use: "export <key_id>",
Short: "Export a PGP key",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
keyID := args[0]
store, err := pgp.NewKeyStore()
if err != nil {
return fmt.Errorf("failed to create PGP store: %w", err)
}
if outputPath == "" {
outputPath = keyID + ".asc"
}
if err := store.ExportKey(keyID, outputPath); err != nil {
return fmt.Errorf("failed to export key: %w", err)
}
fmt.Printf("Exported key to: %s\n", outputPath)
return nil
},
}
cmd.Flags().StringVarP(&outputPath, "output", "o", "", "Output file path")
return cmd
}
func pgpRemoveCmd() *cobra.Command {
return &cobra.Command{
Use: "remove <key_id>",
Short: "Remove a PGP key",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
keyID := args[0]
store, err := pgp.NewKeyStore()
if err != nil {
return fmt.Errorf("failed to create PGP store: %w", err)
}
if err := store.RemoveKey(keyID); err != nil {
return fmt.Errorf("failed to remove key: %w", err)
}
fmt.Printf("Removed key: %s\n", keyID)
return nil
},
}
}
func pgpEncryptCmd() *cobra.Command {
var plaintext, keyID string
cmd := &cobra.Command{
Use: "encrypt <key_id> --plaintext \"text\"",
Short: "Encrypt plaintext with a public key",
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
keyID = args[0]
}
if plaintext == "" {
return fmt.Errorf("plaintext is required (--plaintext)")
}
if keyID == "" {
return fmt.Errorf("key ID is required (positional argument)")
}
store, err := pgp.NewKeyStore()
if err != nil {
return fmt.Errorf("failed to create PGP store: %w", err)
}
encrypted, err := store.EncryptData(keyID, plaintext)
if err != nil {
return fmt.Errorf("failed to encrypt: %w", err)
}
fmt.Println(encrypted)
return nil
},
}
cmd.Flags().StringVarP(&plaintext, "plaintext", "p", "", "Plaintext to encrypt")
cmd.MarkFlagRequired("plaintext")
return cmd
}
func pgpDecryptCmd() *cobra.Command {
var keyID, passphrase, encryptedData string
cmd := &cobra.Command{
Use: "decrypt <key_id>",
Short: "Decrypt PGP-encrypted data",
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
keyID = args[0]
}
store, err := pgp.NewKeyStore()
if err != nil {
return fmt.Errorf("failed to create PGP store: %w", err)
}
decrypted, err := store.DecryptData(keyID, encryptedData, passphrase)
if err != nil {
return fmt.Errorf("failed to decrypt: %w", err)
}
fmt.Println(decrypted)
return nil
},
}
cmd.Flags().StringVarP(&encryptedData, "encrypted", "e", "", "Encrypted data (armored)")
cmd.Flags().StringVarP(&passphrase, "passphrase", "P", "", "Passphrase for private key")
cmd.MarkFlagRequired("encrypted")
return cmd
}
func pgpSignCmd() *cobra.Command {
var plaintext, keyID, passphrase string
cmd := &cobra.Command{
Use: "sign <key_id> --plaintext \"text\"",
Short: "Sign plaintext with a private key",
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
keyID = args[0]
}
if plaintext == "" {
return fmt.Errorf("plaintext is required (--plaintext)")
}
if keyID == "" {
return fmt.Errorf("key ID is required (positional argument)")
}
store, err := pgp.NewKeyStore()
if err != nil {
return fmt.Errorf("failed to create PGP store: %w", err)
}
signature, err := store.SignData(keyID, plaintext, passphrase)
if err != nil {
return fmt.Errorf("failed to sign: %w", err)
}
fmt.Println(signature)
return nil
},
}
cmd.Flags().StringVarP(&plaintext, "plaintext", "p", "", "Plaintext to sign")
cmd.Flags().StringVarP(&passphrase, "passphrase", "P", "", "Passphrase for private key")
cmd.MarkFlagRequired("plaintext")
return cmd
}
func pgpVerifyCmd() *cobra.Command {
var keyID, message, signature string
cmd := &cobra.Command{
Use: "verify <key_id>",
Short: "Verify a detached signature",
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
keyID = args[0]
}
store, err := pgp.NewKeyStore()
if err != nil {
return fmt.Errorf("failed to create PGP store: %w", err)
}
verified, err := store.VerifySignature(keyID, message, signature)
if err != nil {
return fmt.Errorf("verification failed: %w", err)
}
if verified {
fmt.Println("Signature is valid.")
} else {
fmt.Println("Signature is INVALID.")
}
return nil
},
}
cmd.Flags().StringVarP(&message, "message", "m", "", "Message to verify")
cmd.Flags().StringVarP(&signature, "signature", "s", "", "Detached signature")
cmd.MarkFlagRequired("message")
cmd.MarkFlagRequired("signature")
return cmd
}
func init() {
_ = os.Getenv
}

111
cmd/plugin.go Normal file
View File

@@ -0,0 +1,111 @@
package cmd
import (
"fmt"
"os"
"github.com/frenocorp/pop/internal/plugin"
"github.com/spf13/cobra"
)
func pluginCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "plugin",
Short: "Manage plugins",
Long: `List, enable, disable, and manage plugins for the Pop CLI.`,
}
cmd.AddCommand(pluginListCmd())
cmd.AddCommand(pluginEnableCmd())
cmd.AddCommand(pluginDisableCmd())
return cmd
}
func pluginListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all registered plugins",
RunE: func(cmd *cobra.Command, args []string) error {
reg, err := plugin.NewPluginRegistry()
if err != nil {
return fmt.Errorf("failed to create plugin registry: %w", err)
}
plugins, err := reg.ListPlugins()
if err != nil {
return fmt.Errorf("failed to list plugins: %w", err)
}
if len(plugins) == 0 {
fmt.Println("No plugins registered.")
return nil
}
for _, p := range plugins {
fmt.Printf("Name: %s\n Version: %s\n Description: %s\n Binary: %s\n\n",
p.Name, p.Version, p.Description, p.Binary)
}
return nil
},
}
}
func pluginEnableCmd() *cobra.Command {
var name string
cmd := &cobra.Command{
Use: "enable <name>",
Short: "Enable a plugin",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name = args[0]
reg, err := plugin.NewPluginRegistry()
if err != nil {
return fmt.Errorf("failed to create plugin registry: %w", err)
}
if err := reg.EnablePlugin(name); err != nil {
return fmt.Errorf("failed to enable plugin: %w", err)
}
fmt.Printf("Enabled plugin: %s\n", name)
return nil
},
}
return cmd
}
func pluginDisableCmd() *cobra.Command {
var name string
cmd := &cobra.Command{
Use: "disable <name>",
Short: "Disable a plugin",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name = args[0]
reg, err := plugin.NewPluginRegistry()
if err != nil {
return fmt.Errorf("failed to create plugin registry: %w", err)
}
if err := reg.DisablePlugin(name); err != nil {
return fmt.Errorf("failed to disable plugin: %w", err)
}
fmt.Printf("Disabled plugin: %s\n", name)
return nil
},
}
return cmd
}
func init() {
_ = os.Getenv
}

View File

@@ -25,7 +25,7 @@ func newRootCmdBase() *cobra.Command {
It provides commands for managing emails, contacts, and attachments It provides commands for managing emails, contacts, and attachments
with full PGP encryption support.`, with full PGP encryption support.`,
} }
cmd.AddCommand(loginCmd()) cmd.AddCommand(loginCmd())
cmd.AddCommand(logoutCmd()) cmd.AddCommand(logoutCmd())
cmd.AddCommand(sessionCmd()) cmd.AddCommand(sessionCmd())
cmd.AddCommand(mailCmd()) cmd.AddCommand(mailCmd())
@@ -34,6 +34,13 @@ with full PGP encryption support.`,
cmd.AddCommand(attachmentCmd()) cmd.AddCommand(attachmentCmd())
cmd.AddCommand(folderCmd()) cmd.AddCommand(folderCmd())
cmd.AddCommand(labelCmd()) cmd.AddCommand(labelCmd())
cmd.AddCommand(accountsCmd())
cmd.AddCommand(threadCmd())
cmd.AddCommand(bulkCmd())
cmd.AddCommand(exportCmd())
cmd.AddCommand(importCmd())
cmd.AddCommand(draftAutoSaveCmd())
cmd.AddCommand(draftTemplateCmd())
return cmd return cmd
} }
@@ -47,6 +54,16 @@ func NewRootCmd() *cobra.Command {
rootCmd.AddCommand(attachmentCmd()) rootCmd.AddCommand(attachmentCmd())
rootCmd.AddCommand(folderCmd()) rootCmd.AddCommand(folderCmd())
rootCmd.AddCommand(labelCmd()) rootCmd.AddCommand(labelCmd())
rootCmd.AddCommand(accountsCmd())
rootCmd.AddCommand(threadCmd())
rootCmd.AddCommand(bulkCmd())
rootCmd.AddCommand(exportCmd())
rootCmd.AddCommand(importCmd())
rootCmd.AddCommand(draftAutoSaveCmd())
rootCmd.AddCommand(draftTemplateCmd())
rootCmd.AddCommand(webhookCmd())
rootCmd.AddCommand(pgpCmd())
rootCmd.AddCommand(pluginCmd())
return rootCmd return rootCmd
} }

278
cmd/thread.go Normal file
View File

@@ -0,0 +1,278 @@
package cmd
import (
"fmt"
"os"
"strconv"
"strings"
"text/tabwriter"
"github.com/frenocorp/pop/internal/api"
"github.com/frenocorp/pop/internal/config"
internalmail "github.com/frenocorp/pop/internal/mail"
"github.com/spf13/cobra"
)
func threadCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "thread",
Short: "Manage email conversations",
Long: `View, reply to, and manage email conversation threads.`,
}
cmd.AddCommand(threadListCmd())
cmd.AddCommand(threadShowCmd())
cmd.AddCommand(threadReplyCmd())
return cmd
}
func threadListCmd() *cobra.Command {
var page, pageSize string
cmd := &cobra.Command{
Use: "list",
Short: "List conversation threads",
Long: `List email conversation threads sorted by latest activity.`,
RunE: func(cmd *cobra.Command, args []string) error {
pageVal, err := strconv.Atoi(page)
if err != nil || pageVal < 1 {
pageVal = 1
}
pageSizeVal, err := strconv.Atoi(pageSize)
if err != nil || pageSizeVal < 1 {
pageSizeVal = 20
}
if pageSizeVal > 100 {
pageSizeVal = 100
}
cfgMgr := config.NewConfigManager()
cfg, err := cfgMgr.Load()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
session, sessionMgr, err := checkAuthenticatedWithManager()
if err != nil {
return err
}
client := api.NewProtonMailClient(cfg, sessionMgr)
client.SetAuthHeader(session.AccessToken)
mailClient := internalmail.NewClient(client)
result, err := mailClient.ListConversations(pageVal, pageSizeVal, session.MailPassphrase)
if err != nil {
return fmt.Errorf("failed to list conversations: %w", err)
}
if len(result.Conversations) == 0 {
fmt.Println("No conversations found")
return nil
}
return printConversations(result.Conversations)
},
}
cmd.Flags().StringVar(&page, "page", "1", "Page number")
cmd.Flags().StringVar(&pageSize, "page-size", "20", "Conversations per page (max 100)")
return cmd
}
func threadShowCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "show <conversation-id>",
Short: "Show a conversation thread",
Long: `Display all messages in a conversation thread in chronological order.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
convID := args[0]
cfgMgr := config.NewConfigManager()
cfg, err := cfgMgr.Load()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
session, sessionMgr, err := checkAuthenticatedWithManager()
if err != nil {
return err
}
client := api.NewProtonMailClient(cfg, sessionMgr)
client.SetAuthHeader(session.AccessToken)
mailClient := internalmail.NewClient(client)
req := internalmail.GetConversationRequest{
ConversationID: convID,
Passphrase: session.MailPassphrase,
}
result, err := mailClient.GetConversation(req)
if err != nil {
return fmt.Errorf("failed to get conversation: %w", err)
}
fmt.Printf("Conversation: %s\n", result.Subject)
fmt.Printf("Messages: %d\n", result.MessageCount)
fmt.Printf("Participants: %s\n", formatRecipients(result.Participants))
fmt.Println()
for i, msg := range result.Messages {
from := msg.Sender.DisplayName()
date := msg.CreatedAt.Format("2006-01-02 15:04")
subject := msg.Subject
if len(subject) > 60 {
subject = subject[:57] + "..."
}
fmt.Printf("--- Message %d ---\n", i+1)
fmt.Printf("From: %s\n", from)
fmt.Printf("Date: %s\n", date)
fmt.Printf("Subject: %s\n", subject)
if msg.Body != "" {
body := msg.Body
if len(body) > 200 {
body = body[:197] + "..."
}
fmt.Printf("Body: %s\n", body)
}
fmt.Println()
}
return nil
},
}
return cmd
}
func threadReplyCmd() *cobra.Command {
var body, bodyFile, subject string
var html bool
cmd := &cobra.Command{
Use: "reply <conversation-id>",
Short: "Reply to a conversation",
Long: `Reply to the latest message in a conversation thread.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
convID := args[0]
cfgMgr := config.NewConfigManager()
cfg, err := cfgMgr.Load()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
session, sessionMgr, err := checkAuthenticatedWithManager()
if err != nil {
return err
}
client := api.NewProtonMailClient(cfg, sessionMgr)
client.SetAuthHeader(session.AccessToken)
mailClient := internalmail.NewClient(client)
req := internalmail.GetConversationRequest{
ConversationID: convID,
Passphrase: session.MailPassphrase,
}
conv, err := mailClient.GetConversation(req)
if err != nil {
return fmt.Errorf("failed to get conversation: %w", err)
}
if len(conv.Messages) == 0 {
return fmt.Errorf("conversation has no messages")
}
latestMsg := conv.Messages[len(conv.Messages)-1]
var bodyContent string
if body != "" {
bodyContent = body
} else if bodyFile != "" {
data, err := os.ReadFile(bodyFile)
if err != nil {
return fmt.Errorf("failed to read body file: %w", err)
}
bodyContent = string(data)
}
replySubject := subject
if replySubject == "" {
replySubject = "Re: " + latestMsg.Subject
}
replyTo := []internalmail.Recipient{latestMsg.Sender.ToRecipient()}
sendReq := internalmail.SendRequest{
To: replyTo,
Subject: replySubject,
Body: bodyContent,
HTML: html,
InReplyTo: latestMsg.MimeMessageID,
References: latestMsg.ConversationID,
Passphrase: session.MailPassphrase,
}
if err := mailClient.Send(sendReq); err != nil {
return fmt.Errorf("failed to send reply: %w", err)
}
fmt.Println("Reply sent successfully")
return nil
},
}
cmd.Flags().StringVarP(&subject, "subject", "s", "", "Reply subject (default: Re: <original>)")
cmd.Flags().StringVarP(&bodyFile, "body-file", "f", "", "File containing reply body")
cmd.Flags().BoolVar(&html, "html", false, "Send body as HTML")
cmd.Flags().StringVar(&body, "body", "", "Inline reply body")
return cmd
}
func printConversations(conversations []internalmail.Conversation) error {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tSubject\tMessages\tLast From\tLast Date")
fmt.Fprintln(w, "--\t-------\t--------\t---------\t---------")
for _, conv := range conversations {
id := conv.ConversationID
if len(id) > 12 {
id = id[:12]
}
subject := conv.Subject
if len(subject) > 50 {
subject = subject[:47] + "..."
}
lastFrom := "-"
lastDate := "-"
if conv.LastMessage != nil {
lastFrom = conv.LastMessage.Sender.DisplayName()
lastDate = conv.LastMessage.CreatedAt.Format("2006-01-02 15:04")
}
fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s\n", id, subject, conv.MessageCount, lastFrom, lastDate)
}
return w.Flush()
}
func formatThreadParticipants(participants []internalmail.Recipient) string {
parts := make([]string, len(participants))
for i, p := range participants {
parts[i] = p.DisplayName()
}
return strings.Join(parts, ", ")
}

162
cmd/webhook.go Normal file
View File

@@ -0,0 +1,162 @@
package cmd
import (
"fmt"
"os"
"github.com/frenocorp/pop/internal/webhook"
"github.com/spf13/cobra"
)
func webhookCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "webhook",
Short: "Manage webhooks for ProtonMail",
Long: `Add, list, verify, and remove webhooks for real-time ProtonMail notifications.`,
}
cmd.AddCommand(webhookListCmd())
cmd.AddCommand(webhookAddCmd())
cmd.AddCommand(webhookVerifyCmd())
cmd.AddCommand(webhookRemoveCmd())
return cmd
}
func webhookListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all configured webhooks",
RunE: func(cmd *cobra.Command, args []string) error {
store, err := webhook.NewWebhookStore()
if err != nil {
return fmt.Errorf("failed to create webhook store: %w", err)
}
webhooks, err := store.ListWebhooks()
if err != nil {
return fmt.Errorf("failed to list webhooks: %w", err)
}
if len(webhooks) == 0 {
fmt.Println("No webhooks configured.")
return nil
}
for _, wh := range webhooks {
fmt.Printf("Name: %s\n URL: %s\n ID: %s\n Events: %v\n Active: %t\n Created: %s\n\n",
wh.Name, wh.URL, wh.ID, wh.Events, wh.Active, wh.CreatedAt)
}
return nil
},
}
}
func webhookAddCmd() *cobra.Command {
var name, url string
var events []string
cmd := &cobra.Command{
Use: "add <name>",
Short: "Add a webhook endpoint",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name = args[0]
if url == "" {
return fmt.Errorf("webhook URL is required (--url)")
}
if len(events) == 0 {
return fmt.Errorf("at least one event type is required (--events)")
}
eventTypes := make([]webhook.EventType, len(events))
for i, e := range events {
eventTypes[i] = webhook.EventType(e)
}
store, err := webhook.NewWebhookStore()
if err != nil {
return fmt.Errorf("failed to create webhook store: %w", err)
}
wh, err := store.AddWebhook(name, url, eventTypes, "")
if err != nil {
return fmt.Errorf("failed to add webhook: %w", err)
}
fmt.Printf("Webhook added:\n Name: %s\n URL: %s\n ID: %s\n Events: %v\n", wh.Name, wh.URL, wh.ID, wh.Events)
return nil
},
}
cmd.Flags().StringVar(&url, "url", "", "Webhook URL")
cmd.Flags().StringArrayVar(&events, "events", []string{"mail.received"}, "Event types (comma-separated, e.g. mail.received,mail.sent)")
cmd.MarkFlagRequired("url")
cmd.MarkFlagRequired("events")
return cmd
}
func webhookVerifyCmd() *cobra.Command {
return &cobra.Command{
Use: "verify",
Short: "Verify webhook signatures",
RunE: func(cmd *cobra.Command, args []string) error {
store, err := webhook.NewWebhookStore()
if err != nil {
return fmt.Errorf("failed to create webhook store: %w", err)
}
webhooks, err := store.ListWebhooks()
if err != nil {
return fmt.Errorf("failed to list webhooks: %w", err)
}
if len(webhooks) == 0 {
fmt.Println("No webhooks to verify.")
return nil
}
testPayload := []byte(`{"test": true}`)
for _, wh := range webhooks {
valid := webhook.VerifySignature(wh.Secret, testPayload, webhook.ComputeSignature(wh.Secret, testPayload))
if valid {
fmt.Printf("Webhook %s (%s): signature OK\n", wh.ID, wh.URL)
} else {
fmt.Printf("Webhook %s (%s): signature FAILED\n", wh.ID, wh.URL)
}
}
return nil
},
}
}
func webhookRemoveCmd() *cobra.Command {
return &cobra.Command{
Use: "remove <id>",
Short: "Remove a webhook",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
id := args[0]
store, err := webhook.NewWebhookStore()
if err != nil {
return fmt.Errorf("failed to create webhook store: %w", err)
}
if err := store.RemoveWebhook(id); err != nil {
return fmt.Errorf("failed to remove webhook: %w", err)
}
fmt.Printf("Removed webhook: %s\n", id)
return nil
},
}
}
func init() {
_ = os.Getenv
}

View File

@@ -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")
}

View File

@@ -2,45 +2,212 @@ package api
import ( import (
"bytes" "bytes"
"context"
"crypto/rand"
"encoding/binary"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net"
"net/http" "net/http"
"strconv"
"sync" "sync"
"time" "time"
"github.com/frenocorp/pop/internal/config" "github.com/frenocorp/pop/internal/config"
"github.com/frenocorp/pop/internal/auth"
) )
// Code represents a structured API error code returned by ProtonMail.
type Code int
const (
SuccessCode Code = 1000
MultiCode Code = 1001
InvalidValue Code = 2001
AppVersionMissingCode Code = 5001
AppVersionBadCode Code = 5003
UsernameInvalid Code = 6003
PasswordWrong Code = 8002
HumanVerificationRequired Code = 9001
PaidPlanRequired Code = 10004
SessionExpired Code = 10005
TokenExpired Code = 10006
QuotaExceeded Code = 10011
AuthRefreshTokenInvalid Code = 10013
AccountSuspended Code = 10050
HumanValidationInvalidToken Code = 12087
)
// Status tracks the connection state to the ProtonMail API.
type Status int
const (
StatusUp Status = iota
StatusDown
)
func (s Status) String() string {
switch s {
case StatusUp:
return "up"
case StatusDown:
return "down"
default:
return "unknown"
}
}
// StatusObserver is called when the connection status changes.
type StatusObserver func(Status)
// APIHVDetails contains information related to human verification requests.
type APIHVDetails struct {
Methods []string `json:"HumanVerificationMethods"`
Token string `json:"HumanVerificationToken"`
}
// ErrDetails contains optional error details which are specific to each request.
type ErrDetails []byte
func (d ErrDetails) MarshalJSON() ([]byte, error) {
return d, nil
}
func (d *ErrDetails) UnmarshalJSON(data []byte) error {
*d = data
return nil
}
// APIError represents an error returned by the ProtonMail API.
type APIError struct {
// HTTPStatus is the HTTP status code of the response.
HTTPStatus int `json:"-"`
// Code is the structured error code returned by the API.
Code Code `json:"Code,omitempty"`
// Message is the human-readable error message.
Message string `json:"Message,omitempty"`
// Details contains optional error details (serialized JSON).
Details ErrDetails `json:"Details,omitempty"`
}
func (e *APIError) Error() string {
return fmt.Sprintf("API error %d (code=%d): %s", e.HTTPStatus, e.Code, e.Message)
}
// IsHVError returns true if this error requires human verification.
func (e *APIError) IsHVError() bool {
return e.Code == HumanVerificationRequired
}
// GetHVDetails parses the Details field and returns structured HV information.
func (e *APIError) GetHVDetails() (*APIHVDetails, error) {
if !e.IsHVError() {
return nil, fmt.Errorf("not an HV error (code=%d): %w", e.Code, ErrNotHVError)
}
var details APIHVDetails
if err := json.Unmarshal(e.Details, &details); err != nil {
return nil, fmt.Errorf("failed to parse HV details: %w", err)
}
return &details, nil
}
// ErrNotHVError is returned when GetHVDetails is called on a non-HV error.
var ErrNotHVError = errors.New("not a human verification error")
// NetError represents a network-level error when the API is unreachable.
type NetError struct {
// Cause is the underlying error that caused the network error.
Cause error
// Message describes the network error context.
Message string
}
func NewNetError(cause error, message string) *NetError {
return &NetError{Cause: cause, Message: message}
}
func (e *NetError) Error() string {
return fmt.Sprintf("%s: %v", e.Message, e.Cause)
}
func (e *NetError) Unwrap() error {
return e.Cause
}
func (e *NetError) Is(target error) bool {
_, ok := target.(*NetError)
return ok
}
// RetryConfig configures the retry behavior for API requests.
type RetryConfig struct {
// MaxRetries is the maximum number of retry attempts.
MaxRetries int
// MaxWaitTime is the maximum time to wait before a retry.
MaxWaitTime time.Duration
// BaseBackoff is the base delay for exponential backoff.
BaseBackoff time.Duration
}
// DefaultRetryConfig returns sensible defaults matching the official library.
func DefaultRetryConfig() RetryConfig {
return RetryConfig{
MaxRetries: 3,
MaxWaitTime: time.Minute,
BaseBackoff: 500 * time.Millisecond,
}
}
type ProtonMailClient struct { type ProtonMailClient struct {
baseURL string baseURL string
httpClient *http.Client httpClient *http.Client
config *config.Config config *config.Config
rateLimiter *RateLimiter rateLimiter *RateLimiter
authHeader string authHeader string
authMu sync.RWMutex authMu sync.RWMutex
sessionRefresher auth.SessionRefresher
retryConfig RetryConfig
// Connection status tracking
status Status
statusLock sync.Mutex
statusObs []StatusObserver
statusObsMu sync.RWMutex
} }
// RateLimiter implements a sliding window rate limiter.
type RateLimiter struct { type RateLimiter struct {
mu sync.Mutex mu sync.Mutex
requests []time.Time requests []time.Time
limit int limit int
window time.Duration window time.Duration
} }
func NewProtonMailClient(cfg *config.Config) *ProtonMailClient { func NewProtonMailClient(cfg *config.Config, refresher auth.SessionRefresher) *ProtonMailClient {
return &ProtonMailClient{ return &ProtonMailClient{
baseURL: cfg.APIBaseURL, baseURL: cfg.APIBaseURL,
httpClient: &http.Client{Timeout: time.Duration(cfg.TimeoutSec) * time.Second}, httpClient: &http.Client{Timeout: time.Duration(cfg.TimeoutSec) * time.Second},
config: cfg, config: cfg,
sessionRefresher: refresher,
rateLimiter: &RateLimiter{ rateLimiter: &RateLimiter{
requests: make([]time.Time, 0, cfg.RateLimitReq), requests: make([]time.Time, 0, cfg.RateLimitReq),
limit: cfg.RateLimitReq, limit: cfg.RateLimitReq,
window: time.Duration(cfg.RateLimitWin) * time.Second, window: time.Duration(cfg.RateLimitWin) * time.Second,
}, },
retryConfig: DefaultRetryConfig(),
status: StatusUp,
} }
} }
// SetRetryConfig updates the retry configuration.
func (c *ProtonMailClient) SetRetryConfig(rc RetryConfig) {
c.retryConfig = rc
}
func (c *ProtonMailClient) SetAuthHeader(token string) { func (c *ProtonMailClient) SetAuthHeader(token string) {
c.authMu.Lock() c.authMu.Lock()
defer c.authMu.Unlock() defer c.authMu.Unlock()
@@ -53,10 +220,65 @@ func (c *ProtonMailClient) getAuthHeader() string {
return c.authHeader return c.authHeader
} }
func (c *ProtonMailClient) refreshAuth() error {
if c.sessionRefresher == nil {
return fmt.Errorf("no session refresher configured")
}
return c.sessionRefresher.RefreshToken()
}
func (c *ProtonMailClient) GetBaseURL() string { func (c *ProtonMailClient) GetBaseURL() string {
return c.baseURL return c.baseURL
} }
// AddStatusObserver registers a callback for connection status changes.
func (c *ProtonMailClient) AddStatusObserver(observer StatusObserver) {
c.statusObsMu.Lock()
defer c.statusObsMu.Unlock()
c.statusObs = append(c.statusObs, observer)
}
// GetStatus returns the current connection status.
func (c *ProtonMailClient) GetStatus() Status {
c.statusLock.Lock()
defer c.statusLock.Unlock()
return c.status
}
func (c *ProtonMailClient) onConnUp() {
c.statusLock.Lock()
defer c.statusLock.Unlock()
if c.status == StatusUp {
return
}
c.status = StatusUp
c.statusObsMu.RLock()
defer c.statusObsMu.RUnlock()
for _, obs := range c.statusObs {
obs(c.status)
}
}
func (c *ProtonMailClient) onConnDown() {
c.statusLock.Lock()
defer c.statusLock.Unlock()
if c.status == StatusDown {
return
}
c.status = StatusDown
c.statusObsMu.RLock()
defer c.statusObsMu.RUnlock()
for _, obs := range c.statusObs {
obs(c.status)
}
}
func (rl *RateLimiter) Wait() { func (rl *RateLimiter) Wait() {
rl.mu.Lock() rl.mu.Lock()
defer rl.mu.Unlock() defer rl.mu.Unlock()
@@ -64,16 +286,16 @@ func (rl *RateLimiter) Wait() {
now := time.Now() now := time.Now()
windowStart := now.Add(-rl.window) windowStart := now.Add(-rl.window)
// Remove old requests outside the window // In-place filtering to reduce GC pressure
validRequests := make([]time.Time, 0, rl.limit) valid := 0
for _, t := range rl.requests { for _, t := range rl.requests {
if t.After(windowStart) { if t.After(windowStart) {
validRequests = append(validRequests, t) rl.requests[valid] = t
valid++
} }
} }
rl.requests = validRequests rl.requests = rl.requests[:valid]
// Wait if at limit
if len(rl.requests) >= rl.limit { if len(rl.requests) >= rl.limit {
sleep := rl.requests[0].Add(rl.window).Sub(now) sleep := rl.requests[0].Add(rl.window).Sub(now)
if sleep > 0 { if sleep > 0 {
@@ -82,41 +304,278 @@ func (rl *RateLimiter) Wait() {
} }
} }
func (c *ProtonMailClient) recordRequest() {
c.rateLimiter.mu.Lock()
c.rateLimiter.requests = append(c.rateLimiter.requests, time.Now())
c.rateLimiter.mu.Unlock()
}
func (c *ProtonMailClient) Do(req *http.Request) (*http.Response, error) { func (c *ProtonMailClient) Do(req *http.Request) (*http.Response, error) {
return c.DoWithContext(context.Background(), req)
}
func (c *ProtonMailClient) DoWithContext(ctx context.Context, req *http.Request) (*http.Response, error) {
c.rateLimiter.Wait() c.rateLimiter.Wait()
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.getAuthHeader())) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.getAuthHeader()))
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req = req.WithContext(ctx)
resp, err := c.executeWithRetry(ctx, req)
return resp, err
}
// executeWithRetry performs the HTTP request with exponential backoff and retry logic.
func (c *ProtonMailClient) executeWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) {
var lastResp *http.Response
var lastErr error
// Capture request body once so it can be restored on retries.
var bodyBytes []byte
if req.Body != nil {
bodyBytes, _ = io.ReadAll(req.Body)
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
}
for attempt := 0; attempt <= c.retryConfig.MaxRetries; attempt++ {
// Restore body before each retry attempt
if attempt > 0 && len(bodyBytes) > 0 {
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
}
if attempt > 0 {
delay := c.calculateBackoff(attempt, lastResp)
select {
case <-ctx.Done():
if lastResp != nil && lastResp.Body != nil {
lastResp.Body.Close()
}
return lastResp, ctx.Err()
case <-time.After(delay):
}
}
resp, err := c.doSingleRequest(ctx, req)
if err != nil {
lastErr = err
lastResp = nil
if !c.shouldRetryError(err, resp) {
c.onConnDown()
return resp, err
}
continue
}
// Check for 401 and attempt token refresh (single shot, no retry loop)
if resp.StatusCode == http.StatusUnauthorized {
resp.Body.Close()
if err := c.refreshAuth(); err != nil {
return resp, fmt.Errorf("401 received and refresh failed: %w", err)
}
session, err := c.sessionRefresher.GetSession()
if err != nil {
return resp, fmt.Errorf("401 received, refresh succeeded but failed to get new session: %w", err)
}
c.SetAuthHeader(session.AccessToken)
// Clone request for retry with new token
retryReq := req.Clone(ctx)
retryReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.getAuthHeader()))
resp, err = c.doSingleRequest(ctx, retryReq)
if err != nil {
c.onConnDown()
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
return nil, err
}
}
// Check if response should trigger retry
if c.shouldRetryResponse(resp) {
if lastResp != nil {
lastResp.Body.Close()
}
lastResp = resp
continue
}
c.onConnUp()
c.recordRequest()
// Check for API errors (4xx/5xx)
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var apiErr APIError
if err := json.Unmarshal(body, &apiErr); err == nil {
apiErr.HTTPStatus = resp.StatusCode
resp.Body = io.NopCloser(bytes.NewReader(body))
return resp, &apiErr
}
// Non-JSON error response: restore body and let caller handle
resp.Body = io.NopCloser(bytes.NewReader(body))
return resp, nil
}
return resp, nil
}
// Exhausted all retries
if lastResp != nil {
c.onConnUp()
c.recordRequest()
body, _ := io.ReadAll(lastResp.Body)
lastResp.Body.Close()
var apiErr APIError
if err := json.Unmarshal(body, &apiErr); err == nil {
apiErr.HTTPStatus = lastResp.StatusCode
lastResp.Body = io.NopCloser(bytes.NewReader(body))
return lastResp, &apiErr
}
lastResp.Body = io.NopCloser(bytes.NewReader(body))
if lastErr != nil {
return lastResp, lastErr
}
return lastResp, &APIError{
HTTPStatus: lastResp.StatusCode,
Code: 0,
Message: fmt.Sprintf("retries exhausted after %d attempts", c.retryConfig.MaxRetries+1),
}
}
c.onConnDown()
return lastResp, lastErr
}
// doSingleRequest executes a single HTTP request and tracks connection status.
func (c *ProtonMailClient) doSingleRequest(ctx context.Context, req *http.Request) (*http.Response, error) {
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
// Check if it's a network-level error
if netErr := new(net.OpError); errors.As(err, &netErr) {
return nil, NewNetError(netErr, "network error while communicating with API")
}
// Check for dial/connection errors
return nil, err return nil, err
} }
// Record the request if resp.StatusCode == 0 {
c.rateLimiter.mu.Lock() c.onConnDown()
c.rateLimiter.requests = append(c.rateLimiter.requests, time.Now()) return nil, NewNetError(errors.New("no response received"), "received no response from API")
c.rateLimiter.mu.Unlock()
// Check for API errors
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
var apiErr APIError
if err := json.Unmarshal(body, &apiErr); err == nil {
resp.Body = io.NopCloser(io.MultiReader(io.NopCloser(bytes.NewReader(body)), bytes.NewReader(body)))
return resp, &apiErr
}
} }
return resp, nil return resp, nil
} }
type APIError struct { // shouldRetryError determines if an error condition warrants a retry.
HTTPStatus int `json:"-"` func (c *ProtonMailClient) shouldRetryError(err error, resp *http.Response) bool {
Code int `json:"Code,omitempty"` if err == nil {
Message string `json:"Message,omitempty"` return false
}
// Context errors are not retryable
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
// Network errors (NetError wraps net.OpError) are retryable
if _, ok := errors.Unwrap(err).(*NetError); ok {
return true
}
if _, ok := err.(*NetError); ok {
return true
}
// Raw net.OpError from http.Client.Do are retryable
if _, ok := err.(*net.OpError); ok {
return true
}
return false
} }
func (e *APIError) Error() string { // shouldRetryResponse determines if a response status warrants a retry.
return fmt.Sprintf("API error %d: %s", e.HTTPStatus, e.Message) func (c *ProtonMailClient) shouldRetryResponse(resp *http.Response) bool {
if resp == nil {
return false
}
return resp.StatusCode == http.StatusTooManyRequests ||
resp.StatusCode == http.StatusServiceUnavailable
}
// calculateBackoff computes the retry delay using exponential backoff with jitter.
// If the response contains a Retry-After header, that value is used as the base.
func (c *ProtonMailClient) calculateBackoff(attempt int, resp *http.Response) time.Duration {
var delay time.Duration
// Check for Retry-After header first
if resp != nil {
retryAfter := c.parseRetryAfter(resp)
if retryAfter > 0 {
delay = retryAfter
}
}
// Fall back to exponential backoff
if delay == 0 {
base := c.retryConfig.BaseBackoff
delay = base * (1 << uint(attempt)) // Exponential: 0.5s, 1s, 2s, ...
}
// Cap at max wait time
if delay > c.retryConfig.MaxWaitTime {
delay = c.retryConfig.MaxWaitTime
}
// Add jitter (0-10 seconds) to avoid thundering herd
jitter := time.Duration(c.randIntn(10)) * time.Second
delay += jitter
return delay
}
// randIntn returns a thread-safe random integer in [0, n) using crypto/rand.
func (c *ProtonMailClient) randIntn(n int) int {
b := make([]byte, 4)
_, _ = rand.Read(b)
return int(binary.BigEndian.Uint32(b) % uint32(n))
}
// parseRetryAfter parses the Retry-After header and returns the duration.
// Returns 0 if the header is missing or invalid.
func (c *ProtonMailClient) parseRetryAfter(resp *http.Response) time.Duration {
retryAfterStr := resp.Header.Get("Retry-After")
if retryAfterStr == "" {
return 0
}
// Try parsing as seconds (integer)
seconds, err := strconv.Atoi(retryAfterStr)
if err != nil {
// Try parsing as HTTP date
t, err := time.Parse(time.RFC1123, retryAfterStr)
if err != nil {
return 0
}
delay := t.Sub(time.Now())
if delay < 0 {
delay = 0
}
return delay
}
return time.Duration(seconds) * time.Second
} }

View File

@@ -0,0 +1,11 @@
package auth
import "context"
// SessionRefresher defines the interface for refreshing authentication tokens.
// This allows the API client to automatically refresh tokens on 401 responses.
type SessionRefresher interface {
RefreshToken() error
RefreshTokenWithContext(ctx context.Context) error
GetSession() (*Session, error)
}

View File

@@ -2,6 +2,7 @@ package auth
import ( import (
"bytes" "bytes"
"context"
"crypto/aes" "crypto/aes"
"crypto/cipher" "crypto/cipher"
"crypto/rand" "crypto/rand"
@@ -54,7 +55,7 @@ func NewSessionManager() (*SessionManager, error) {
}, nil }, nil
} }
func (m *SessionManager) LoginWithCredentials(apiBaseURL, email, password, mailPassphrase string) error { func (m *SessionManager) LoginWithCredentials(ctx context.Context, apiBaseURL, email, password, mailPassphrase string) error {
if err := os.MkdirAll(m.configDir, 0700); err != nil { if err := os.MkdirAll(m.configDir, 0700); err != nil {
return fmt.Errorf("failed to create config dir: %w", err) return fmt.Errorf("failed to create config dir: %w", err)
} }
@@ -75,7 +76,7 @@ func (m *SessionManager) LoginWithCredentials(apiBaseURL, email, password, mailP
return fmt.Errorf("failed to marshal auth payload: %w", err) return fmt.Errorf("failed to marshal auth payload: %w", err)
} }
req, err := http.NewRequest("POST", authURL, bytes.NewBuffer(jsonData)) req, err := http.NewRequestWithContext(ctx, "POST", authURL, bytes.NewBuffer(jsonData))
if err != nil { if err != nil {
return fmt.Errorf("failed to create auth request: %w", err) return fmt.Errorf("failed to create auth request: %w", err)
} }
@@ -95,11 +96,11 @@ func (m *SessionManager) LoginWithCredentials(apiBaseURL, email, password, mailP
} }
var authResponse struct { var authResponse struct {
UID string `json:"UID"` UID string `json:"UID"`
AccessToken string `json:"AccessToken"` AccessToken string `json:"AccessToken"`
RefreshToken string `json:"RefreshToken"` RefreshToken string `json:"RefreshToken"`
ExpiresIn int `json:"ExpiresIn"` ExpiresIn int `json:"ExpiresIn"`
TwoFARequired bool `json:"TwoFARequired"` TwoFARequired bool `json:"TwoFARequired"`
} }
if err := json.NewDecoder(resp.Body).Decode(&authResponse); err != nil { if err := json.NewDecoder(resp.Body).Decode(&authResponse); err != nil {
@@ -135,7 +136,7 @@ func (m *SessionManager) LoginWithCredentials(apiBaseURL, email, password, mailP
return nil return nil
} }
func (m *SessionManager) LoginInteractive(apiBaseURL string) error { func (m *SessionManager) LoginInteractive(ctx context.Context, apiBaseURL string) error {
if err := os.MkdirAll(m.configDir, 0700); err != nil { if err := os.MkdirAll(m.configDir, 0700); err != nil {
return fmt.Errorf("failed to create config dir: %w", err) return fmt.Errorf("failed to create config dir: %w", err)
} }
@@ -192,7 +193,7 @@ func (m *SessionManager) LoginInteractive(apiBaseURL string) error {
return fmt.Errorf("failed to marshal auth payload: %w", err) return fmt.Errorf("failed to marshal auth payload: %w", err)
} }
req, err := http.NewRequest("POST", authURL, bytes.NewBuffer(jsonData)) req, err := http.NewRequestWithContext(ctx, "POST", authURL, bytes.NewBuffer(jsonData))
if err != nil { if err != nil {
return fmt.Errorf("failed to create auth request: %w", err) return fmt.Errorf("failed to create auth request: %w", err)
} }
@@ -263,7 +264,7 @@ func (m *SessionManager) LoginInteractive(apiBaseURL string) error {
return fmt.Errorf("failed to marshal TOTP payload: %w", err) return fmt.Errorf("failed to marshal TOTP payload: %w", err)
} }
totpReq, err := http.NewRequest("POST", totpURL, bytes.NewBuffer(totpJSON)) totpReq, err := http.NewRequestWithContext(ctx, "POST", totpURL, bytes.NewBuffer(totpJSON))
if err != nil { if err != nil {
return fmt.Errorf("failed to create TOTP request: %w", err) return fmt.Errorf("failed to create TOTP request: %w", err)
} }
@@ -372,6 +373,10 @@ func (m *SessionManager) IsAuthenticated() (bool, error) {
} }
func (m *SessionManager) RefreshToken() error { func (m *SessionManager) RefreshToken() error {
return m.RefreshTokenWithContext(context.Background())
}
func (m *SessionManager) RefreshTokenWithContext(ctx context.Context) error {
session, err := m.GetSession() session, err := m.GetSession()
if err != nil { if err != nil {
return fmt.Errorf("failed to get session: %w", err) return fmt.Errorf("failed to get session: %w", err)
@@ -394,7 +399,7 @@ func (m *SessionManager) RefreshToken() error {
return fmt.Errorf("failed to marshal refresh payload: %w", err) return fmt.Errorf("failed to marshal refresh payload: %w", err)
} }
req, err := http.NewRequest("POST", refreshURL, bytes.NewBuffer(jsonData)) req, err := http.NewRequestWithContext(ctx, "POST", refreshURL, bytes.NewBuffer(jsonData))
if err != nil { if err != nil {
return fmt.Errorf("failed to create refresh request: %w", err) return fmt.Errorf("failed to create refresh request: %w", err)
} }

View File

@@ -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*

View File

@@ -7,6 +7,8 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"os"
"time"
"github.com/frenocorp/pop/internal/api" "github.com/frenocorp/pop/internal/api"
) )
@@ -56,12 +58,13 @@ func (c *Client) ListMessages(req ListMessagesRequest) (*ListMessagesResponse, e
return nil, fmt.Errorf("failed to marshal request: %w", err) return nil, fmt.Errorf("failed to marshal request: %w", err)
} }
reqURL := fmt.Sprintf("%s/api/messages", c.baseURL) reqURL := fmt.Sprintf("%s/mail/v4/messages", c.baseURL)
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody)) httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err) return nil, fmt.Errorf("failed to create request: %w", err)
} }
httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("X-HTTP-Method-Override", "GET")
resp, err := c.apiClient.Do(httpReq) resp, err := c.apiClient.Do(httpReq)
if err != nil { if err != nil {
@@ -83,21 +86,15 @@ func (c *Client) ListMessages(req ListMessagesRequest) (*ListMessagesResponse, e
} }
func (c *Client) GetMessage(messageID string, passphrase string) (*Message, error) { func (c *Client) GetMessage(messageID string, passphrase string) (*Message, error) {
body := map[string]string{ var result struct {
"Passphrase": passphrase, Message Message `json:"Message"`
} }
jsonBody, err := json.Marshal(body) reqURL := fmt.Sprintf("%s/mail/v4/messages/%s", c.baseURL, url.QueryEscape(messageID))
if err != nil { httpReq, err := http.NewRequest("GET", reqURL, nil)
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
reqURL := fmt.Sprintf("%s/api/messages/%s", c.baseURL, url.QueryEscape(messageID))
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err) return nil, fmt.Errorf("failed to create request: %w", err)
} }
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.apiClient.Do(httpReq) resp, err := c.apiClient.Do(httpReq)
if err != nil { if err != nil {
@@ -110,14 +107,11 @@ func (c *Client) GetMessage(messageID string, passphrase string) (*Message, erro
return nil, fmt.Errorf("failed to read response: %w", err) return nil, fmt.Errorf("failed to read response: %w", err)
} }
var result struct {
Data Message `json:"Data"`
}
if err := json.Unmarshal(respBody, &result); err != nil { if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err) return nil, fmt.Errorf("failed to parse response: %w", err)
} }
return &result.Data, nil return &result.Message, nil
} }
func (c *Client) Send(req SendRequest) error { func (c *Client) Send(req SendRequest) error {
@@ -154,7 +148,7 @@ func (c *Client) Send(req SendRequest) error {
return fmt.Errorf("failed to marshal request: %w", err) return fmt.Errorf("failed to marshal request: %w", err)
} }
reqURL := fmt.Sprintf("%s/api/messages", c.baseURL) reqURL := fmt.Sprintf("%s/mail/v4/messages", c.baseURL)
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody)) httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody))
if err != nil { if err != nil {
return fmt.Errorf("failed to create request: %w", err) return fmt.Errorf("failed to create request: %w", err)
@@ -176,14 +170,21 @@ func (c *Client) Send(req SendRequest) error {
} }
func (c *Client) MoveToTrash(messageID string, passphrase string) error { func (c *Client) MoveToTrash(messageID string, passphrase string) error {
formData := url.Values{} body := map[string]string{
formData.Set("Passphrase", passphrase) "Passphrase": passphrase,
reqURL := fmt.Sprintf("%s/api/messages/%s/movetotrash", c.baseURL, url.QueryEscape(messageID)) }
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBufferString(formData.Encode()))
jsonBody, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
reqURL := fmt.Sprintf("%s/mail/v4/messages/%s/trash", c.baseURL, url.QueryEscape(messageID))
httpReq, err := http.NewRequest("PUT", reqURL, bytes.NewBuffer(jsonBody))
if err != nil { if err != nil {
return fmt.Errorf("failed to create request: %w", err) return fmt.Errorf("failed to create request: %w", err)
} }
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.apiClient.Do(httpReq) resp, err := c.apiClient.Do(httpReq)
if err != nil { if err != nil {
@@ -200,8 +201,8 @@ func (c *Client) MoveToTrash(messageID string, passphrase string) error {
} }
func (c *Client) PermanentlyDelete(messageID string) error { func (c *Client) PermanentlyDelete(messageID string) error {
reqURL := fmt.Sprintf("%s/api/messages/%s/delete", c.baseURL, url.QueryEscape(messageID)) reqURL := fmt.Sprintf("%s/mail/v4/messages/%s", c.baseURL, url.QueryEscape(messageID))
httpReq, err := http.NewRequest("POST", reqURL, nil) httpReq, err := http.NewRequest("DELETE", reqURL, nil)
if err != nil { if err != nil {
return fmt.Errorf("failed to create request: %w", err) return fmt.Errorf("failed to create request: %w", err)
} }
@@ -242,7 +243,7 @@ func (c *Client) SaveDraft(draft Draft, passphrase string) (string, error) {
return "", fmt.Errorf("failed to marshal request: %w", err) return "", fmt.Errorf("failed to marshal request: %w", err)
} }
reqURL := fmt.Sprintf("%s/api/messages", c.baseURL) reqURL := fmt.Sprintf("%s/mail/v4/messages", c.baseURL)
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody)) httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody))
if err != nil { if err != nil {
return "", fmt.Errorf("failed to create request: %w", err) return "", fmt.Errorf("failed to create request: %w", err)
@@ -261,27 +262,29 @@ func (c *Client) SaveDraft(draft Draft, passphrase string) (string, error) {
} }
var result struct { var result struct {
Data struct { Message struct {
MessageID string `json:"MessageID"` MessageID string `json:"MessageID"`
} `json:"Data"` } `json:"Message"`
} }
if err := json.Unmarshal(respBody, &result); err != nil { if err := json.Unmarshal(respBody, &result); err != nil {
return "", fmt.Errorf("failed to parse response: %w", err) return "", fmt.Errorf("failed to parse response: %w", err)
} }
return result.Data.MessageID, nil return result.Message.MessageID, nil
} }
func (c *Client) UpdateDraft(messageID string, draft Draft, passphrase string) error { func (c *Client) UpdateDraft(messageID string, draft Draft, passphrase string) error {
body := map[string]interface{}{ body := map[string]interface{}{
"Passphrase": passphrase, "Message": map[string]interface{}{
"Subject": draft.Subject, "Passphrase": passphrase,
"To": draft.To, "Subject": draft.Subject,
"Body": draft.Body, "To": draft.To,
"Body": draft.Body,
},
} }
if len(draft.CC) > 0 { if len(draft.CC) > 0 {
body["CC"] = draft.CC body["Message"].(map[string]interface{})["CC"] = draft.CC
} }
jsonBody, err := json.Marshal(body) jsonBody, err := json.Marshal(body)
@@ -289,8 +292,8 @@ func (c *Client) UpdateDraft(messageID string, draft Draft, passphrase string) e
return fmt.Errorf("failed to marshal request: %w", err) return fmt.Errorf("failed to marshal request: %w", err)
} }
reqURL := fmt.Sprintf("%s/api/messages/%s", c.baseURL, url.QueryEscape(messageID)) reqURL := fmt.Sprintf("%s/mail/v4/messages/%s", c.baseURL, url.QueryEscape(messageID))
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody)) httpReq, err := http.NewRequest("PUT", reqURL, bytes.NewBuffer(jsonBody))
if err != nil { if err != nil {
return fmt.Errorf("failed to create request: %w", err) return fmt.Errorf("failed to create request: %w", err)
} }
@@ -311,14 +314,21 @@ func (c *Client) UpdateDraft(messageID string, draft Draft, passphrase string) e
} }
func (c *Client) SendDraft(messageID string, passphrase string) error { func (c *Client) SendDraft(messageID string, passphrase string) error {
formData := url.Values{} body := map[string]string{
formData.Set("Passphrase", passphrase) "Passphrase": passphrase,
reqURL := fmt.Sprintf("%s/api/messages/%s/send", c.baseURL, url.QueryEscape(messageID)) }
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBufferString(formData.Encode()))
jsonBody, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
reqURL := fmt.Sprintf("%s/mail/v4/messages/%s", c.baseURL, url.QueryEscape(messageID))
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody))
if err != nil { if err != nil {
return fmt.Errorf("failed to create request: %w", err) return fmt.Errorf("failed to create request: %w", err)
} }
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.apiClient.Do(httpReq) resp, err := c.apiClient.Do(httpReq)
if err != nil { if err != nil {
@@ -357,7 +367,7 @@ func (c *Client) SearchMessages(req SearchRequest) (*SearchResponse, error) {
return nil, fmt.Errorf("failed to marshal request: %w", err) return nil, fmt.Errorf("failed to marshal request: %w", err)
} }
reqURL := fmt.Sprintf("%s/api/messages/search", c.baseURL) reqURL := fmt.Sprintf("%s/mail/v4/messages/search", c.baseURL)
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody)) httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err) return nil, fmt.Errorf("failed to create request: %w", err)
@@ -382,3 +392,335 @@ func (c *Client) SearchMessages(req SearchRequest) (*SearchResponse, error) {
return &result, nil 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
}

View File

@@ -32,22 +32,25 @@ func newMockServer(t *testing.T) *mockServer {
server: srv, 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) 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) 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) 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) 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) 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) resolveHandler(ms, w, r)
}) })
@@ -72,8 +75,8 @@ func resolveHandler(ms *mockServer, w http.ResponseWriter, r *http.Request) {
handler.(http.HandlerFunc)(w, r) handler.(http.HandlerFunc)(w, r)
return return
} }
// When POST /api/messages is called, it matches both list and send/draft. // When POST /mail/v4/messages is called, it matches both list and send/draft.
// The generic handler for POST /api/messages catches all unmatched POST /api/messages calls. // The generic handler for POST /mail/v4/messages catches all unmatched POST /mail/v4/messages calls.
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, `{"Code":404,"Message":"not found"}`) fmt.Fprintf(w, `{"Code":404,"Message":"not found"}`)
} }
@@ -87,7 +90,7 @@ func newTestClient(t *testing.T, srv *mockServer) *Client {
RateLimitReq: 100, RateLimitReq: 100,
RateLimitWin: 60, RateLimitWin: 60,
} }
apiClient := api.NewProtonMailClient(cfg) apiClient := api.NewProtonMailClient(cfg, nil)
apiClient.SetAuthHeader("test-token") apiClient.SetAuthHeader("test-token")
return NewClient(apiClient) return NewClient(apiClient)
} }
@@ -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) body := readJSON(t, r)
if body["Page"] != float64(1) { if body["Page"] != float64(1) {
t.Errorf("expected page 1, got %v", body["Page"]) t.Errorf("expected page 1, got %v", body["Page"])
@@ -167,7 +170,7 @@ func TestListMessages_WithFolderFilter(t *testing.T) {
defer srv.Close() defer srv.Close()
client := newTestClient(t, srv) 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) body := readJSON(t, r)
if body["Type"] != float64(FolderSent) { if body["Type"] != float64(FolderSent) {
t.Errorf("expected Type=3 (sent), got %v", body["Type"]) t.Errorf("expected Type=3 (sent), got %v", body["Type"])
@@ -191,7 +194,7 @@ func TestListMessages_InboxOmitsType(t *testing.T) {
defer srv.Close() defer srv.Close()
client := newTestClient(t, srv) 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) body := readJSON(t, r)
if _, ok := body["Type"]; ok { if _, ok := body["Type"]; ok {
t.Error("Inbox should omit Type field") t.Error("Inbox should omit Type field")
@@ -216,7 +219,7 @@ func TestListMessages_WithStarredFilter(t *testing.T) {
client := newTestClient(t, srv) client := newTestClient(t, srv)
starred := true 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) body := readJSON(t, r)
if body["Starred"] != true { if body["Starred"] != true {
t.Errorf("expected Starred=true, got %v", body["Starred"]) t.Errorf("expected Starred=true, got %v", body["Starred"])
@@ -242,7 +245,7 @@ func TestListMessages_WithReadFilter(t *testing.T) {
client := newTestClient(t, srv) client := newTestClient(t, srv)
unread := false 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) body := readJSON(t, r)
if body["Read"] != false { if body["Read"] != false {
t.Errorf("expected Read=false, got %v", body["Read"]) t.Errorf("expected Read=false, got %v", body["Read"])
@@ -268,7 +271,7 @@ func TestListMessages_WithSinceFilter(t *testing.T) {
client := newTestClient(t, srv) client := newTestClient(t, srv)
since := int64(1700000000) 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) body := readJSON(t, r)
if body["Since"] != float64(since) { if body["Since"] != float64(since) {
t.Errorf("expected Since=%d, got %v", since, body["Since"]) t.Errorf("expected Since=%d, got %v", since, body["Since"])
@@ -293,9 +296,9 @@ func TestListMessages_APIError(t *testing.T) {
defer srv.Close() defer srv.Close()
client := newTestClient(t, srv) 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.StatusUnauthorized) w.WriteHeader(http.StatusForbidden)
fmt.Fprintf(w, `{"Code":401,"Message":"invalid token"}`) fmt.Fprintf(w, `{"Code":403,"Message":"invalid token"}`)
}) })
_, err := client.ListMessages(ListMessagesRequest{ _, err := client.ListMessages(ListMessagesRequest{
@@ -304,7 +307,7 @@ func TestListMessages_APIError(t *testing.T) {
Passphrase: "pass", Passphrase: "pass",
}) })
if err == nil { if err == nil {
t.Fatal("expected error for 401 response") t.Fatal("expected error for 403 response")
} }
if !strings.Contains(err.Error(), "invalid token") { if !strings.Contains(err.Error(), "invalid token") {
t.Errorf("expected 'invalid token' in error, got: %s", err.Error()) t.Errorf("expected 'invalid token' in error, got: %s", err.Error())
@@ -316,7 +319,7 @@ func TestListMessages_BadJSON(t *testing.T) {
defer srv.Close() defer srv.Close()
client := newTestClient(t, srv) 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) w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"bad json`) fmt.Fprint(w, `{"bad json`)
}) })
@@ -348,12 +351,8 @@ func TestGetMessage_Success(t *testing.T) {
Body: "Decrypted body content", Body: "Decrypted body content",
} }
srv.Handle("POST /api/messages/msg-42", func(w http.ResponseWriter, r *http.Request) { srv.Handle("GET /mail/v4/messages/msg-42", func(w http.ResponseWriter, r *http.Request) {
body := readJSON(t, r) writeJSON(t, w, http.StatusOK, map[string]interface{}{"Message": expectedMsg})
if body["Passphrase"] != "pass" {
t.Errorf("expected passphrase pass, got %v", body["Passphrase"])
}
writeJSON(t, w, http.StatusOK, map[string]interface{}{"Data": expectedMsg})
}) })
msg, err := client.GetMessage("msg-42", "pass") msg, err := client.GetMessage("msg-42", "pass")
@@ -377,8 +376,8 @@ func TestGetMessage_URLEscape(t *testing.T) {
client := newTestClient(t, srv) client := newTestClient(t, srv)
msgID := "msg/with/slashes" msgID := "msg/with/slashes"
srv.Handle("POST /api/messages/msg/with/slashes", func(w http.ResponseWriter, r *http.Request) { srv.Handle("GET /mail/v4/messages/msg/with/slashes", func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, http.StatusOK, map[string]interface{}{"Data": Message{MessageID: msgID}}) writeJSON(t, w, http.StatusOK, map[string]interface{}{"Message": Message{MessageID: msgID}})
}) })
msg, err := client.GetMessage(msgID, "pass") msg, err := client.GetMessage(msgID, "pass")
@@ -395,7 +394,7 @@ func TestGetMessage_NotFound(t *testing.T) {
defer srv.Close() defer srv.Close()
client := newTestClient(t, srv) 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) w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, `{"Code":404,"Message":"message not found"}`) fmt.Fprintf(w, `{"Code":404,"Message":"message not found"}`)
}) })
@@ -404,6 +403,9 @@ func TestGetMessage_NotFound(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected error for 404") t.Fatal("expected error for 404")
} }
if !strings.Contains(err.Error(), "message not found") {
t.Errorf("expected 'message not found' in error, got: %s", err.Error())
}
} }
func TestGetMessage_DecryptBody(t *testing.T) { func TestGetMessage_DecryptBody(t *testing.T) {
@@ -417,7 +419,7 @@ func TestGetMessage_DecryptBody(t *testing.T) {
RateLimitReq: 100, RateLimitReq: 100,
RateLimitWin: 60, RateLimitWin: 60,
} }
apiClient := api.NewProtonMailClient(cfg) apiClient := api.NewProtonMailClient(cfg, nil)
apiClient.SetAuthHeader("test-token") apiClient.SetAuthHeader("test-token")
client := NewClient(apiClient) client := NewClient(apiClient)
client.SetPGPService(svc) client.SetPGPService(svc)
@@ -434,8 +436,8 @@ func TestGetMessage_DecryptBody(t *testing.T) {
BodyEnc: encryptedBody, BodyEnc: encryptedBody,
} }
srv.Handle("POST /api/messages/msg-enc", func(w http.ResponseWriter, r *http.Request) { srv.Handle("GET /mail/v4/messages/msg-enc", func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, http.StatusOK, map[string]interface{}{"Data": msgWithEncryptedBody}) writeJSON(t, w, http.StatusOK, map[string]interface{}{"Message": msgWithEncryptedBody})
}) })
msg, err := client.GetMessage("msg-enc", pass) msg, err := client.GetMessage("msg-enc", pass)
@@ -454,7 +456,7 @@ func TestSend_Success(t *testing.T) {
defer srv.Close() defer srv.Close()
client := newTestClient(t, srv) 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) body := readJSON(t, r)
if body["Subject"] != "Test Subject" { if body["Subject"] != "Test Subject" {
t.Errorf("expected subject Test Subject, got %v", body["Subject"]) t.Errorf("expected subject Test Subject, got %v", body["Subject"])
@@ -488,12 +490,12 @@ func TestSend_WithPGP(t *testing.T) {
RateLimitReq: 100, RateLimitReq: 100,
RateLimitWin: 60, RateLimitWin: 60,
} }
apiClient := api.NewProtonMailClient(cfg) apiClient := api.NewProtonMailClient(cfg, nil)
apiClient.SetAuthHeader("test-token") apiClient.SetAuthHeader("test-token")
client := NewClient(apiClient) client := NewClient(apiClient)
client.SetPGPService(svc) 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) body := readJSON(t, r)
// When PGP service is set, BodyEnc should be present instead of Body // When PGP service is set, BodyEnc should be present instead of Body
if _, hasBody := body["Body"]; hasBody { if _, hasBody := body["Body"]; hasBody {
@@ -521,7 +523,7 @@ func TestSend_WithCC(t *testing.T) {
defer srv.Close() defer srv.Close()
client := newTestClient(t, srv) 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) body := readJSON(t, r)
cc, ok := body["CC"].([]interface{}) cc, ok := body["CC"].([]interface{})
if !ok || len(cc) != 1 { if !ok || len(cc) != 1 {
@@ -546,7 +548,7 @@ func TestSend_WithBCC(t *testing.T) {
defer srv.Close() defer srv.Close()
client := newTestClient(t, srv) 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) body := readJSON(t, r)
bcc, ok := body["BCC"].([]interface{}) bcc, ok := body["BCC"].([]interface{})
if !ok || len(bcc) != 1 { if !ok || len(bcc) != 1 {
@@ -571,7 +573,7 @@ func TestSend_HTTPError(t *testing.T) {
defer srv.Close() defer srv.Close()
client := newTestClient(t, srv) 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) w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"Code":400,"Message":"invalid recipient"}`) fmt.Fprintf(w, `{"Code":400,"Message":"invalid recipient"}`)
}) })
@@ -595,7 +597,7 @@ func TestSend_CreatedStatus(t *testing.T) {
defer srv.Close() defer srv.Close()
client := newTestClient(t, srv) 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) w.WriteHeader(http.StatusCreated)
fmt.Fprintf(w, `{"Data":{"MessageID":"created-1"}}`) fmt.Fprintf(w, `{"Data":{"MessageID":"created-1"}}`)
}) })
@@ -617,13 +619,13 @@ func TestMoveToTrash_Success(t *testing.T) {
defer srv.Close() defer srv.Close()
client := newTestClient(t, srv) 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) {
if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" { if r.Header.Get("Content-Type") != "application/json" {
t.Error("expected form-urlencoded content type") t.Error("expected json content type")
} }
body, _ := io.ReadAll(r.Body) body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), "Passphrase=pass") { if !strings.Contains(string(body), `"Passphrase":"pass"`) {
t.Errorf("expected Passphrase in form body, got %s", body) t.Errorf("expected Passphrase in json body, got %s", body)
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}) })
@@ -639,7 +641,7 @@ func TestMoveToTrash_Error(t *testing.T) {
defer srv.Close() defer srv.Close()
client := newTestClient(t, srv) 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) w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, "server error") fmt.Fprint(w, "server error")
}) })
@@ -660,7 +662,7 @@ func TestPermanentlyDelete_Success(t *testing.T) {
defer srv.Close() defer srv.Close()
client := newTestClient(t, srv) 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) w.WriteHeader(http.StatusOK)
}) })
@@ -675,7 +677,7 @@ func TestPermanentlyDelete_Error(t *testing.T) {
defer srv.Close() defer srv.Close()
client := newTestClient(t, srv) 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) w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, `{"Code":404,"Message":"not found"}`) fmt.Fprintf(w, `{"Code":404,"Message":"not found"}`)
}) })
@@ -696,7 +698,7 @@ func TestPermanentlyDelete_URLEscape(t *testing.T) {
client := newTestClient(t, srv) client := newTestClient(t, srv)
msgID := "msg/with/slashes" 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) w.WriteHeader(http.StatusOK)
}) })
@@ -713,7 +715,7 @@ func TestSaveDraft_Success(t *testing.T) {
defer srv.Close() defer srv.Close()
client := newTestClient(t, srv) 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) body := readJSON(t, r)
if body["Type"] != MessageTypeDraft { if body["Type"] != MessageTypeDraft {
t.Errorf("expected Type=%s, got %v", MessageTypeDraft, body["Type"]) t.Errorf("expected Type=%s, got %v", MessageTypeDraft, body["Type"])
@@ -722,7 +724,7 @@ func TestSaveDraft_Success(t *testing.T) {
t.Errorf("expected subject Draft Subject, got %v", body["Subject"]) t.Errorf("expected subject Draft Subject, got %v", body["Subject"])
} }
writeJSON(t, w, http.StatusOK, map[string]interface{}{ writeJSON(t, w, http.StatusOK, map[string]interface{}{
"Data": map[string]string{"MessageID": "draft-1"}, "Message": map[string]string{"MessageID": "draft-1"},
}) })
}) })
@@ -744,7 +746,7 @@ func TestSaveDraft_WithCC(t *testing.T) {
defer srv.Close() defer srv.Close()
client := newTestClient(t, srv) 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) body := readJSON(t, r)
if _, ok := body["CC"]; !ok { if _, ok := body["CC"]; !ok {
t.Error("expected CC field") t.Error("expected CC field")
@@ -774,7 +776,7 @@ func TestSaveDraft_Error(t *testing.T) {
defer srv.Close() defer srv.Close()
client := newTestClient(t, srv) 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) w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"Code":400,"Message":"invalid recipient"}`) fmt.Fprintf(w, `{"Code":400,"Message":"invalid recipient"}`)
}) })
@@ -794,7 +796,7 @@ func TestSaveDraft_BadJSON(t *testing.T) {
defer srv.Close() defer srv.Close()
client := newTestClient(t, srv) 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) w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"bad json`) fmt.Fprint(w, `{"bad json`)
}) })
@@ -819,10 +821,11 @@ func TestUpdateDraft_Success(t *testing.T) {
defer srv.Close() defer srv.Close()
client := newTestClient(t, srv) 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) body := readJSON(t, r)
if body["Subject"] != "Updated Subject" { msg := body["Message"].(map[string]interface{})
t.Errorf("expected Updated Subject, got %v", body["Subject"]) if msg["Subject"] != "Updated Subject" {
t.Errorf("expected Updated Subject, got %v", msg["Subject"])
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}) })
@@ -842,9 +845,10 @@ func TestUpdateDraft_WithCC(t *testing.T) {
defer srv.Close() defer srv.Close()
client := newTestClient(t, srv) 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) 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") t.Error("expected CC field in update")
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
@@ -866,7 +870,7 @@ func TestUpdateDraft_Error(t *testing.T) {
defer srv.Close() defer srv.Close()
client := newTestClient(t, srv) 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) w.WriteHeader(http.StatusConflict)
fmt.Fprintf(w, `{"Code":409,"Message":"draft locked"}`) fmt.Fprintf(w, `{"Code":409,"Message":"draft locked"}`)
}) })
@@ -892,13 +896,13 @@ func TestSendDraft_Success(t *testing.T) {
defer srv.Close() defer srv.Close()
client := newTestClient(t, srv) 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) {
if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" { if r.Header.Get("Content-Type") != "application/json" {
t.Error("expected form-urlencoded content type") t.Error("expected json content type")
} }
body, _ := io.ReadAll(r.Body) body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), "Passphrase=pass") { if !strings.Contains(string(body), `"Passphrase":"pass"`) {
t.Errorf("expected Passphrase in form, got %s", body) t.Errorf("expected Passphrase in json, got %s", body)
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}) })
@@ -914,7 +918,7 @@ func TestSendDraft_Error(t *testing.T) {
defer srv.Close() defer srv.Close()
client := newTestClient(t, srv) 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) w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, "not found") fmt.Fprint(w, "not found")
}) })
@@ -942,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) body := readJSON(t, r)
if body["Type"] != float64(FolderDraft) { if body["Type"] != float64(FolderDraft) {
t.Errorf("expected Type=2 (draft), got %v", body["Type"]) t.Errorf("expected Type=2 (draft), got %v", body["Type"])
@@ -975,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) body := readJSON(t, r)
if body["Query"] != "invoice" { if body["Query"] != "invoice" {
t.Errorf("expected query invoice, got %v", body["Query"]) t.Errorf("expected query invoice, got %v", body["Query"])
@@ -1011,7 +1015,7 @@ func TestSearchMessages_EmptyResults(t *testing.T) {
defer srv.Close() defer srv.Close()
client := newTestClient(t, srv) 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{}}) writeJSON(t, w, http.StatusOK, SearchResponse{Total: 0, Messages: []Message{}})
}) })
@@ -1034,7 +1038,7 @@ func TestSearchMessages_APIError(t *testing.T) {
defer srv.Close() defer srv.Close()
client := newTestClient(t, srv) 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) w.WriteHeader(http.StatusTooManyRequests)
fmt.Fprintf(w, `{"Code":429,"Message":"rate limited"}`) fmt.Fprintf(w, `{"Code":429,"Message":"rate limited"}`)
}) })
@@ -1058,7 +1062,7 @@ func TestSearchMessages_BadJSON(t *testing.T) {
defer srv.Close() defer srv.Close()
client := newTestClient(t, srv) 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) w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `not json at all`) fmt.Fprint(w, `not json at all`)
}) })
@@ -1089,12 +1093,12 @@ func TestAuthHeader_Propagated(t *testing.T) {
RateLimitReq: 100, RateLimitReq: 100,
RateLimitWin: 60, RateLimitWin: 60,
} }
apiClient := api.NewProtonMailClient(cfg) apiClient := api.NewProtonMailClient(cfg, nil)
apiClient.SetAuthHeader("my-test-token") apiClient.SetAuthHeader("my-test-token")
client := NewClient(apiClient) client := NewClient(apiClient)
var capturedAuth string 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") capturedAuth = r.Header.Get("Authorization")
writeJSON(t, w, http.StatusOK, ListMessagesResponse{}) writeJSON(t, w, http.StatusOK, ListMessagesResponse{})
}) })
@@ -1117,7 +1121,7 @@ func TestContentTypes_JSON(t *testing.T) {
defer srv.Close() defer srv.Close()
client := newTestClient(t, srv) 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") ct := r.Header.Get("Content-Type")
if ct != "application/json" { if ct != "application/json" {
t.Errorf("expected application/json, got %s", ct) t.Errorf("expected application/json, got %s", ct)
@@ -1137,10 +1141,10 @@ func TestContentTypes_FormUrlEncoded(t *testing.T) {
defer srv.Close() defer srv.Close()
client := newTestClient(t, srv) 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") ct := r.Header.Get("Content-Type")
if ct != "application/x-www-form-urlencoded" { if ct != "application/json" {
t.Errorf("expected application/x-www-form-urlencoded, got %s", ct) t.Errorf("expected application/json, got %s", ct)
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}) })
@@ -1158,7 +1162,7 @@ func TestListMessages_Concurrent(t *testing.T) {
var mu sync.Mutex var mu sync.Mutex
callCount := 0 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() mu.Lock()
callCount++ callCount++
mu.Unlock() mu.Unlock()
@@ -1264,7 +1268,7 @@ func TestSetPGPService(t *testing.T) {
svc, _, _ := newTestService(t) svc, _, _ := newTestService(t)
client.SetPGPService(svc) 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) body := readJSON(t, r)
// PGP service should cause BodyEnc instead of Body // PGP service should cause BodyEnc instead of Body
if _, hasBody := body["Body"]; hasBody { if _, hasBody := body["Body"]; hasBody {
@@ -1291,7 +1295,7 @@ func TestSend_WithoutBody(t *testing.T) {
defer srv.Close() defer srv.Close()
client := newTestClient(t, srv) 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) body := readJSON(t, r)
if _, hasBody := body["Body"]; hasBody { if _, hasBody := body["Body"]; hasBody {
t.Error("Body should be omitted when empty") t.Error("Body should be omitted when empty")
@@ -1324,11 +1328,11 @@ func TestListMessages_Timeout(t *testing.T) {
RateLimitReq: 100, RateLimitReq: 100,
RateLimitWin: 60, RateLimitWin: 60,
} }
apiClient := api.NewProtonMailClient(cfg) apiClient := api.NewProtonMailClient(cfg, nil)
apiClient.SetAuthHeader("test-token") apiClient.SetAuthHeader("test-token")
client := NewClient(apiClient) 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) time.Sleep(2 * time.Second)
writeJSON(t, w, http.StatusOK, ListMessagesResponse{}) writeJSON(t, w, http.StatusOK, ListMessagesResponse{})
}) })
@@ -1354,7 +1358,7 @@ func TestListMessages_CombinedFilters(t *testing.T) {
unread := false unread := false
since := int64(1700000000) 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) body := readJSON(t, r)
if body["Type"] != float64(FolderSent) { if body["Type"] != float64(FolderSent) {
t.Errorf("expected Type=3, got %v", body["Type"]) t.Errorf("expected Type=3, got %v", body["Type"])

View File

@@ -78,6 +78,13 @@ func (r Recipient) DisplayName() string {
return r.Address return r.Address
} }
func (r Recipient) ToRecipient() Recipient {
return Recipient{
Name: r.Name,
Address: r.Address,
}
}
type Attachment struct { type Attachment struct {
AttachmentID string `json:"AttachmentID"` AttachmentID string `json:"AttachmentID"`
Name string `json:"Name"` Name string `json:"Name"`
@@ -118,15 +125,17 @@ type ListMessagesResponse struct {
} }
type SendRequest struct { type SendRequest struct {
To []Recipient `json:"To"` To []Recipient `json:"To"`
CC []Recipient `json:"CC,omitempty"` CC []Recipient `json:"CC,omitempty"`
BCC []Recipient `json:"BCC,omitempty"` BCC []Recipient `json:"BCC,omitempty"`
Subject string `json:"Subject"` Subject string `json:"Subject"`
Body string `json:"Body"` Body string `json:"Body"`
HTML bool `json:"HTML,omitempty"` HTML bool `json:"HTML,omitempty"`
ReplyTo []Recipient `json:"ReplyTo,omitempty"` ReplyTo []Recipient `json:"ReplyTo,omitempty"`
InReplyTo string `json:"InReplyTo,omitempty"`
References string `json:"References,omitempty"`
Attachments []Attachment `json:"Attachments,omitempty"` Attachments []Attachment `json:"Attachments,omitempty"`
Passphrase string `json:"Passphrase"` Passphrase string `json:"Passphrase"`
} }
type SearchRequest struct { type SearchRequest struct {
@@ -140,3 +149,118 @@ type SearchResponse struct {
Total int `json:"Total"` Total int `json:"Total"`
Messages []Message `json:"Messages"` 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"`
}

465
internal/pgp/pgp.go Normal file
View File

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

291
internal/plugin/plugin.go Normal file
View File

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

375
internal/webhook/webhook.go Normal file
View File

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