Compare commits
15 Commits
6cc520e221
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e9edc2ae1 | |||
| d28834831a | |||
| 2cffa1ead7 | |||
| bf26cd3ed6 | |||
|
|
e7e77fcc20 | ||
| 6663ebc778 | |||
| c8ffe76688 | |||
| 2b8051efb1 | |||
|
|
5dc4a1b742 | ||
| 691a2acdad | |||
| 19a9e2a3df | |||
| d53b8ec8bc | |||
| a78c564e23 | |||
| ced8204ef8 | |||
|
|
90bee9119e |
27
.github/workflows/ci.yml
vendored
27
.github/workflows/ci.yml
vendored
@@ -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
|
||||||
@@ -21,14 +21,33 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version: ${{ matrix.go-version }}
|
go-version: ${{ matrix.go-version }}
|
||||||
|
|
||||||
|
- name: Cache Go modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/go-build
|
||||||
|
~/go/pkg/mod
|
||||||
|
key: ${{ runner.os }}-go-${{ matrix.go-version }}-${{ hashFiles('**/go.sum') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-go-${{ matrix.go-version }}-
|
||||||
|
|
||||||
- name: Download dependencies
|
- name: Download dependencies
|
||||||
run: go mod download
|
run: go mod download
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: go build -v ./...
|
run: go build -v ./...
|
||||||
|
|
||||||
- name: Test
|
- name: Test with coverage and enforce threshold
|
||||||
run: go test -v -race ./...
|
run: |
|
||||||
|
go test -v -race -coverprofile=coverage.out -covermode=atomic ./... 2>&1
|
||||||
|
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"}'
|
||||||
|
|
||||||
|
- name: Upload coverage report
|
||||||
|
uses: codecov/codecov-action@v4
|
||||||
|
with:
|
||||||
|
files: ./coverage.out
|
||||||
|
flags: unittests
|
||||||
|
name: codecov-pop
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: |
|
run: |
|
||||||
@@ -43,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
278
README.md
@@ -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
210
cmd/accounts.go
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
|
"github.com/frenocorp/pop/internal/accounts"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func accountsCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "accounts",
|
||||||
|
Short: "Manage multiple ProtonMail accounts",
|
||||||
|
Long: `Add, list, switch, and remove named ProtonMail account profiles.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(accountsListCmd())
|
||||||
|
cmd.AddCommand(accountsAddCmd())
|
||||||
|
cmd.AddCommand(accountsRemoveCmd())
|
||||||
|
cmd.AddCommand(accountsDefaultCmd())
|
||||||
|
cmd.AddCommand(accountsShowCmd())
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func accountsListCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List all configured accounts",
|
||||||
|
Long: `Show all saved ProtonMail account profiles.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
store, err := accounts.NewAccountsStore()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create accounts store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
accts, err := store.LoadAccounts()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load accounts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(accts) == 0 {
|
||||||
|
fmt.Println("No accounts configured. Use 'pop accounts add' to create one.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "Name\tEmail\tDefault\tAPI Base\tCreated")
|
||||||
|
fmt.Fprintln(w, "----\t-----\t-------\t--------\t-------")
|
||||||
|
|
||||||
|
for _, acc := range accts {
|
||||||
|
def := "-"
|
||||||
|
if acc.Default {
|
||||||
|
def = "*"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
|
||||||
|
acc.Name, acc.Email, def, acc.APIBaseURL, acc.CreatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
return w.Flush()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func accountsAddCmd() *cobra.Command {
|
||||||
|
var name, email, apiURL string
|
||||||
|
var isDefault bool
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "add <name>",
|
||||||
|
Short: "Add a new account profile",
|
||||||
|
Long: `Add a named ProtonMail account profile for multi-account support.`,
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
accountName := name
|
||||||
|
if len(args) > 0 && args[0] != "" {
|
||||||
|
accountName = args[0]
|
||||||
|
}
|
||||||
|
if accountName == "" {
|
||||||
|
return fmt.Errorf("account name is required")
|
||||||
|
}
|
||||||
|
if email == "" {
|
||||||
|
return fmt.Errorf("email is required (--email)")
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := accounts.NewAccountsStore()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create accounts store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.AddAccount(accountName, email, apiURL, isDefault); err != nil {
|
||||||
|
return fmt.Errorf("failed to add account: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Added account: %s (%s)\n", accountName, email)
|
||||||
|
if isDefault {
|
||||||
|
fmt.Println("Set as default account")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVar(&name, "name", "", "Account name (or pass as positional argument)")
|
||||||
|
cmd.Flags().StringVarP(&email, "email", "e", "", "ProtonMail email address")
|
||||||
|
cmd.Flags().StringVar(&apiURL, "api-url", "", "Custom API base URL")
|
||||||
|
cmd.Flags().BoolVarP(&isDefault, "default", "d", false, "Set as default account")
|
||||||
|
_ = cmd.MarkFlagRequired("email")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func accountsRemoveCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "remove <name>",
|
||||||
|
Short: "Remove an account profile",
|
||||||
|
Long: `Remove a named ProtonMail account profile.`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
accountName := args[0]
|
||||||
|
|
||||||
|
store, err := accounts.NewAccountsStore()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create accounts store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.RemoveAccount(accountName); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove account: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Removed account: %s\n", accountName)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func accountsDefaultCmd() *cobra.Command {
|
||||||
|
var setName string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "default [name]",
|
||||||
|
Short: "Get or set the default account",
|
||||||
|
Long: `Show the current default account, or set a new default.`,
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
store, err := accounts.NewAccountsStore()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create accounts store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if setName == "" && len(args) > 0 {
|
||||||
|
setName = args[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if setName != "" {
|
||||||
|
if err := store.SetDefaultAccount(setName); err != nil {
|
||||||
|
return fmt.Errorf("failed to set default account: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Set default account to: %s\n", setName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
acct, err := store.GetAccount("")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("no default account: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Default account: %s (%s)\n", acct.Name, acct.Email)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVar(&setName, "set", "", "Set default account to this name")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func accountsShowCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "show <name>",
|
||||||
|
Short: "Show account details",
|
||||||
|
Long: `Display details for a specific account profile.`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
accountName := args[0]
|
||||||
|
|
||||||
|
store, err := accounts.NewAccountsStore()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create accounts store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
acct, err := store.GetAccount(accountName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get account: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Name: %s\n", acct.Name)
|
||||||
|
fmt.Printf("Email: %s\n", acct.Email)
|
||||||
|
fmt.Printf("UID: %s\n", acct.UID)
|
||||||
|
fmt.Printf("API Base: %s\n", acct.APIBaseURL)
|
||||||
|
fmt.Printf("Default: %t\n", acct.Default)
|
||||||
|
fmt.Printf("Created: %s\n", acct.CreatedAt)
|
||||||
|
if acct.LastUsedAt != "" {
|
||||||
|
fmt.Printf("Last Used: %s\n", acct.LastUsedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
152
cmd/auth_test.go
Normal file
152
cmd/auth_test.go
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestLoginCommand tests the login CLI command
|
||||||
|
func TestLoginCommand(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Create a temporary config file
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := tmpDir + "/config.yaml"
|
||||||
|
|
||||||
|
configContent := `
|
||||||
|
api:
|
||||||
|
base_url: "http://localhost:8080"
|
||||||
|
timeout: 30s
|
||||||
|
`
|
||||||
|
err := os.WriteFile(configPath, []byte(configContent), 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set config path environment variable
|
||||||
|
os.Setenv("POP_CONFIG_PATH", configPath)
|
||||||
|
defer os.Unsetenv("POP_CONFIG_PATH")
|
||||||
|
|
||||||
|
// Create root command with login subcommand
|
||||||
|
rootCmd := NewRootCmd()
|
||||||
|
rootCmd.SetArgs([]string{"login"})
|
||||||
|
|
||||||
|
// Capture output
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
|
||||||
|
// Execute command
|
||||||
|
err = rootCmd.Execute()
|
||||||
|
if err != nil {
|
||||||
|
// Login requires interactive input, so error is expected in non-interactive mode
|
||||||
|
t.Logf("Login command executed with error (expected in non-interactive mode): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify command ran
|
||||||
|
output := buf.String()
|
||||||
|
t.Logf("Command output: %s", output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLogoutCommand tests the logout CLI command
|
||||||
|
func TestLogoutCommand(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Create a temporary config file
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := tmpDir + "/config.yaml"
|
||||||
|
|
||||||
|
err := os.WriteFile(configPath, []byte("{}"), 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("POP_CONFIG_PATH", configPath)
|
||||||
|
defer os.Unsetenv("POP_CONFIG_PATH")
|
||||||
|
|
||||||
|
// Create root command with logout subcommand
|
||||||
|
rootCmd := NewRootCmd()
|
||||||
|
rootCmd.SetArgs([]string{"logout"})
|
||||||
|
|
||||||
|
// Capture output
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
|
||||||
|
// Execute command
|
||||||
|
err = rootCmd.Execute()
|
||||||
|
// Logout may fail if no session exists, which is expected
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Logout command executed with error (expected if no session): %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSessionCommand tests the session CLI command
|
||||||
|
func TestSessionCommand(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Create a temporary config file
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := tmpDir + "/config.yaml"
|
||||||
|
|
||||||
|
err := os.WriteFile(configPath, []byte("{}"), 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv("POP_CONFIG_PATH", configPath)
|
||||||
|
defer os.Unsetenv("POP_CONFIG_PATH")
|
||||||
|
|
||||||
|
// Create root command with session subcommand
|
||||||
|
rootCmd := NewRootCmd()
|
||||||
|
rootCmd.SetArgs([]string{"session"})
|
||||||
|
|
||||||
|
// Capture output
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
rootCmd.SetErr(&buf)
|
||||||
|
|
||||||
|
// Execute command
|
||||||
|
err = rootCmd.Execute()
|
||||||
|
// Session may fail if no active session, which is expected
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Session command executed with error (expected if no session): %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRootCommandHelp tests the help output
|
||||||
|
func TestRootCommandHelp(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rootCmd := NewRootCmd()
|
||||||
|
rootCmd.SetArgs([]string{"--help"})
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Help command failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, _ := io.ReadAll(&buf)
|
||||||
|
if len(output) == 0 {
|
||||||
|
t.Error("Help output is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify help contains expected commands
|
||||||
|
helpText := string(output)
|
||||||
|
expectedCommands := []string{"login", "logout", "session", "mail", "contact", "attachment", "folder", "draft"}
|
||||||
|
for _, cmd := range expectedCommands {
|
||||||
|
if !contains(helpText, cmd) {
|
||||||
|
t.Errorf("Help output missing command: %s", cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to check if string contains substring
|
||||||
|
func contains(s, substr string) bool {
|
||||||
|
return len(s) > 0 && len(substr) > 0 && (s == substr || len(s) > len(substr) && (s[:len(substr)] == substr || contains(s[1:], substr)))
|
||||||
|
}
|
||||||
365
cmd/bulk.go
Normal file
365
cmd/bulk.go
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/frenocorp/pop/internal/api"
|
||||||
|
"github.com/frenocorp/pop/internal/config"
|
||||||
|
internalmail "github.com/frenocorp/pop/internal/mail"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func bulkCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "bulk",
|
||||||
|
Short: "Bulk operations on messages",
|
||||||
|
Long: `Perform operations on multiple messages at once: delete, trash, star, mark read.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(bulkDeleteCmd())
|
||||||
|
cmd.AddCommand(bulkTrashCmd())
|
||||||
|
cmd.AddCommand(bulkStarCmd())
|
||||||
|
cmd.AddCommand(bulkUnstarCmd())
|
||||||
|
cmd.AddCommand(bulkMarkReadCmd())
|
||||||
|
cmd.AddCommand(bulkMarkUnreadCmd())
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func bulkDeleteCmd() *cobra.Command {
|
||||||
|
var ids, idsFile string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "delete",
|
||||||
|
Short: "Permanently delete multiple messages",
|
||||||
|
Long: `Permanently delete multiple messages from ProtonMail. Use --ids for comma-separated IDs or --ids-file for a file with one ID per line.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
messageIDs := collectMessageIDs(ids, idsFile)
|
||||||
|
if len(messageIDs) == 0 {
|
||||||
|
return fmt.Errorf("no message IDs provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgMgr := config.NewConfigManager()
|
||||||
|
cfg, err := cfgMgr.Load()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session, sessionMgr, err := checkAuthenticatedWithManager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := api.NewProtonMailClient(cfg, sessionMgr)
|
||||||
|
client.SetAuthHeader(session.AccessToken)
|
||||||
|
mailClient := internalmail.NewClient(client)
|
||||||
|
|
||||||
|
result, err := mailClient.BulkDelete(messageIDs)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to bulk delete: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Deleted %d/%d messages\n", result.SuccessCount, result.Total)
|
||||||
|
if len(result.Errors) > 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "Errors:")
|
||||||
|
for _, e := range result.Errors {
|
||||||
|
fmt.Fprintf(os.Stderr, " - %s: %s\n", e.MessageID, e.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&ids, "ids", "i", "", "Comma-separated message IDs")
|
||||||
|
cmd.Flags().StringVarP(&idsFile, "ids-file", "f", "", "File with one message ID per line")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func bulkTrashCmd() *cobra.Command {
|
||||||
|
var ids, idsFile string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "trash",
|
||||||
|
Short: "Move multiple messages to trash",
|
||||||
|
Long: `Move multiple messages to the trash folder. Use --ids for comma-separated IDs or --ids-file for a file with one ID per line.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
messageIDs := collectMessageIDs(ids, idsFile)
|
||||||
|
if len(messageIDs) == 0 {
|
||||||
|
return fmt.Errorf("no message IDs provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgMgr := config.NewConfigManager()
|
||||||
|
cfg, err := cfgMgr.Load()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session, sessionMgr, err := checkAuthenticatedWithManager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := api.NewProtonMailClient(cfg, sessionMgr)
|
||||||
|
client.SetAuthHeader(session.AccessToken)
|
||||||
|
mailClient := internalmail.NewClient(client)
|
||||||
|
|
||||||
|
result, err := mailClient.BulkTrash(messageIDs, session.MailPassphrase)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to bulk trash: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Trashed %d/%d messages\n", result.SuccessCount, result.Total)
|
||||||
|
if len(result.Errors) > 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "Errors:")
|
||||||
|
for _, e := range result.Errors {
|
||||||
|
fmt.Fprintf(os.Stderr, " - %s: %s\n", e.MessageID, e.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&ids, "ids", "i", "", "Comma-separated message IDs")
|
||||||
|
cmd.Flags().StringVarP(&idsFile, "ids-file", "f", "", "File with one message ID per line")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func bulkStarCmd() *cobra.Command {
|
||||||
|
var ids, idsFile string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "star",
|
||||||
|
Short: "Star multiple messages",
|
||||||
|
Long: `Star multiple messages at once. Use --ids for comma-separated IDs or --ids-file for a file with one ID per line.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
messageIDs := collectMessageIDs(ids, idsFile)
|
||||||
|
if len(messageIDs) == 0 {
|
||||||
|
return fmt.Errorf("no message IDs provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgMgr := config.NewConfigManager()
|
||||||
|
cfg, err := cfgMgr.Load()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session, sessionMgr, err := checkAuthenticatedWithManager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := api.NewProtonMailClient(cfg, sessionMgr)
|
||||||
|
client.SetAuthHeader(session.AccessToken)
|
||||||
|
mailClient := internalmail.NewClient(client)
|
||||||
|
|
||||||
|
result, err := mailClient.BulkStar(messageIDs, true)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to bulk star: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Starred %d/%d messages\n", result.SuccessCount, result.Total)
|
||||||
|
if len(result.Errors) > 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "Errors:")
|
||||||
|
for _, e := range result.Errors {
|
||||||
|
fmt.Fprintf(os.Stderr, " - %s: %s\n", e.MessageID, e.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&ids, "ids", "i", "", "Comma-separated message IDs")
|
||||||
|
cmd.Flags().StringVarP(&idsFile, "ids-file", "f", "", "File with one message ID per line")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func bulkUnstarCmd() *cobra.Command {
|
||||||
|
var ids, idsFile string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "unstar",
|
||||||
|
Short: "Unstar multiple messages",
|
||||||
|
Long: `Unstar multiple messages at once. Use --ids for comma-separated IDs or --ids-file for a file with one ID per line.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
messageIDs := collectMessageIDs(ids, idsFile)
|
||||||
|
if len(messageIDs) == 0 {
|
||||||
|
return fmt.Errorf("no message IDs provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgMgr := config.NewConfigManager()
|
||||||
|
cfg, err := cfgMgr.Load()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session, sessionMgr, err := checkAuthenticatedWithManager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := api.NewProtonMailClient(cfg, sessionMgr)
|
||||||
|
client.SetAuthHeader(session.AccessToken)
|
||||||
|
mailClient := internalmail.NewClient(client)
|
||||||
|
|
||||||
|
result, err := mailClient.BulkStar(messageIDs, false)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to bulk unstar: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Unstarred %d/%d messages\n", result.SuccessCount, result.Total)
|
||||||
|
if len(result.Errors) > 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "Errors:")
|
||||||
|
for _, e := range result.Errors {
|
||||||
|
fmt.Fprintf(os.Stderr, " - %s: %s\n", e.MessageID, e.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&ids, "ids", "i", "", "Comma-separated message IDs")
|
||||||
|
cmd.Flags().StringVarP(&idsFile, "ids-file", "f", "", "File with one message ID per line")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func bulkMarkReadCmd() *cobra.Command {
|
||||||
|
var ids, idsFile string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "mark-read",
|
||||||
|
Short: "Mark multiple messages as read",
|
||||||
|
Long: `Mark multiple messages as read at once. Use --ids for comma-separated IDs or --ids-file for a file with one ID per line.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
messageIDs := collectMessageIDs(ids, idsFile)
|
||||||
|
if len(messageIDs) == 0 {
|
||||||
|
return fmt.Errorf("no message IDs provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgMgr := config.NewConfigManager()
|
||||||
|
cfg, err := cfgMgr.Load()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session, sessionMgr, err := checkAuthenticatedWithManager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := api.NewProtonMailClient(cfg, sessionMgr)
|
||||||
|
client.SetAuthHeader(session.AccessToken)
|
||||||
|
mailClient := internalmail.NewClient(client)
|
||||||
|
|
||||||
|
result, err := mailClient.BulkMarkRead(messageIDs, true)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to bulk mark read: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Marked %d/%d messages as read\n", result.SuccessCount, result.Total)
|
||||||
|
if len(result.Errors) > 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "Errors:")
|
||||||
|
for _, e := range result.Errors {
|
||||||
|
fmt.Fprintf(os.Stderr, " - %s: %s\n", e.MessageID, e.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&ids, "ids", "i", "", "Comma-separated message IDs")
|
||||||
|
cmd.Flags().StringVarP(&idsFile, "ids-file", "f", "", "File with one message ID per line")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func bulkMarkUnreadCmd() *cobra.Command {
|
||||||
|
var ids, idsFile string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "mark-unread",
|
||||||
|
Short: "Mark multiple messages as unread",
|
||||||
|
Long: `Mark multiple messages as unread at once. Use --ids for comma-separated IDs or --ids-file for a file with one ID per line.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
messageIDs := collectMessageIDs(ids, idsFile)
|
||||||
|
if len(messageIDs) == 0 {
|
||||||
|
return fmt.Errorf("no message IDs provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgMgr := config.NewConfigManager()
|
||||||
|
cfg, err := cfgMgr.Load()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session, sessionMgr, err := checkAuthenticatedWithManager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := api.NewProtonMailClient(cfg, sessionMgr)
|
||||||
|
client.SetAuthHeader(session.AccessToken)
|
||||||
|
mailClient := internalmail.NewClient(client)
|
||||||
|
|
||||||
|
result, err := mailClient.BulkMarkRead(messageIDs, false)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to bulk mark unread: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Marked %d/%d messages as unread\n", result.SuccessCount, result.Total)
|
||||||
|
if len(result.Errors) > 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "Errors:")
|
||||||
|
for _, e := range result.Errors {
|
||||||
|
fmt.Fprintf(os.Stderr, " - %s: %s\n", e.MessageID, e.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&ids, "ids", "i", "", "Comma-separated message IDs")
|
||||||
|
cmd.Flags().StringVarP(&idsFile, "ids-file", "f", "", "File with one message ID per line")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectMessageIDs(ids, idsFile string) []string {
|
||||||
|
var messageIDs []string
|
||||||
|
|
||||||
|
if idsFile != "" {
|
||||||
|
data, err := os.ReadFile(idsFile)
|
||||||
|
if err != nil {
|
||||||
|
return messageIDs
|
||||||
|
}
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
messageIDs = append(messageIDs, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ids != "" {
|
||||||
|
for _, id := range strings.Split(ids, ",") {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id != "" {
|
||||||
|
messageIDs = append(messageIDs, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return messageIDs
|
||||||
|
}
|
||||||
16
cmd/draft.go
16
cmd/draft.go
@@ -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
606
cmd/draft_autosave.go
Normal 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)
|
||||||
|
}
|
||||||
1475
cmd/e2e_full_test.go
Normal file
1475
cmd/e2e_full_test.go
Normal file
File diff suppressed because it is too large
Load Diff
190
cmd/e2e_test.go
Normal file
190
cmd/e2e_test.go
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMailCommand tests the mail CLI command structure
|
||||||
|
func TestMailCommand(t *testing.T) {
|
||||||
|
|
||||||
|
|
||||||
|
rootCmd := newRootCmdBase()
|
||||||
|
rootCmd.SetArgs([]string{"mail", "--help"})
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Mail help command failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, _ := io.ReadAll(&buf)
|
||||||
|
helpText := string(output)
|
||||||
|
|
||||||
|
// Verify mail subcommands are present
|
||||||
|
expectedSubcommands := []string{"list", "read", "send", "delete", "trash", "draft", "search"}
|
||||||
|
for _, subcmd := range expectedSubcommands {
|
||||||
|
if !contains(helpText, subcmd) {
|
||||||
|
t.Errorf("Mail help missing subcommand: %s", subcmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMailListCommand tests the mail list subcommand
|
||||||
|
func TestMailListCommand(t *testing.T) {
|
||||||
|
|
||||||
|
|
||||||
|
rootCmd := newRootCmdBase()
|
||||||
|
rootCmd.SetArgs([]string{"mail", "list", "--help"})
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Mail list help command failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, _ := io.ReadAll(&buf)
|
||||||
|
if len(output) == 0 {
|
||||||
|
t.Error("Mail list help output is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestContactCommand tests the contact CLI command structure
|
||||||
|
func TestContactCommand(t *testing.T) {
|
||||||
|
|
||||||
|
|
||||||
|
rootCmd := newRootCmdBase()
|
||||||
|
rootCmd.SetArgs([]string{"contact", "--help"})
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Contact help command failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, _ := io.ReadAll(&buf)
|
||||||
|
helpText := string(output)
|
||||||
|
|
||||||
|
// Verify contact subcommands are present
|
||||||
|
expectedSubcommands := []string{"list", "add", "edit", "delete"}
|
||||||
|
for _, subcmd := range expectedSubcommands {
|
||||||
|
if !contains(helpText, subcmd) {
|
||||||
|
t.Errorf("Contact help missing subcommand: %s", subcmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAttachmentCommand tests the attachment CLI command structure
|
||||||
|
func TestAttachmentCommand(t *testing.T) {
|
||||||
|
|
||||||
|
|
||||||
|
rootCmd := newRootCmdBase()
|
||||||
|
rootCmd.SetArgs([]string{"attachment", "--help"})
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Attachment help command failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, _ := io.ReadAll(&buf)
|
||||||
|
helpText := string(output)
|
||||||
|
|
||||||
|
// Verify attachment subcommands are present
|
||||||
|
expectedSubcommands := []string{"upload", "download", "list"}
|
||||||
|
for _, subcmd := range expectedSubcommands {
|
||||||
|
if !contains(helpText, subcmd) {
|
||||||
|
t.Errorf("Attachment help missing subcommand: %s", subcmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFolderCommand tests the folder CLI command structure
|
||||||
|
func TestFolderCommand(t *testing.T) {
|
||||||
|
|
||||||
|
|
||||||
|
rootCmd := newRootCmdBase()
|
||||||
|
rootCmd.SetArgs([]string{"folder", "--help"})
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Folder help command failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, _ := io.ReadAll(&buf)
|
||||||
|
helpText := string(output)
|
||||||
|
|
||||||
|
// Verify folder subcommands are present
|
||||||
|
expectedSubcommands := []string{"list", "create", "update", "delete"}
|
||||||
|
for _, subcmd := range expectedSubcommands {
|
||||||
|
if !contains(helpText, subcmd) {
|
||||||
|
t.Errorf("Folder help missing subcommand: %s", subcmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLabelCommand tests the label CLI command structure
|
||||||
|
func TestLabelCommand(t *testing.T) {
|
||||||
|
|
||||||
|
|
||||||
|
rootCmd := newRootCmdBase()
|
||||||
|
rootCmd.SetArgs([]string{"label", "--help"})
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Label help command failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, _ := io.ReadAll(&buf)
|
||||||
|
helpText := string(output)
|
||||||
|
|
||||||
|
// Verify label subcommands are present
|
||||||
|
expectedSubcommands := []string{"list", "create", "update", "delete", "apply", "remove"}
|
||||||
|
for _, subcmd := range expectedSubcommands {
|
||||||
|
if !contains(helpText, subcmd) {
|
||||||
|
t.Errorf("Label help missing subcommand: %s", subcmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDraftCommand tests the draft CLI command structure
|
||||||
|
func TestDraftCommand(t *testing.T) {
|
||||||
|
|
||||||
|
|
||||||
|
rootCmd := newRootCmdBase()
|
||||||
|
rootCmd.SetArgs([]string{"draft", "--help"})
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rootCmd.SetOut(&buf)
|
||||||
|
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Draft help command failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, _ := io.ReadAll(&buf)
|
||||||
|
helpText := string(output)
|
||||||
|
|
||||||
|
// Verify draft subcommands are present
|
||||||
|
expectedSubcommands := []string{"list", "save", "edit", "send"}
|
||||||
|
for _, subcmd := range expectedSubcommands {
|
||||||
|
if !contains(helpText, subcmd) {
|
||||||
|
t.Errorf("Draft help missing subcommand: %s", subcmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
446
cmd/export.go
Normal file
446
cmd/export.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
34
cmd/mail.go
34
cmd/mail.go
@@ -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
299
cmd/pgp.go
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/frenocorp/pop/internal/pgp"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func pgpCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "pgp",
|
||||||
|
Short: "Manage PGP keys",
|
||||||
|
Long: `Import, export, list, and manage PGP keys for ProtonMail encryption.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(pgpListCmd())
|
||||||
|
cmd.AddCommand(pgpImportCmd())
|
||||||
|
cmd.AddCommand(pgpExportCmd())
|
||||||
|
cmd.AddCommand(pgpRemoveCmd())
|
||||||
|
cmd.AddCommand(pgpEncryptCmd())
|
||||||
|
cmd.AddCommand(pgpDecryptCmd())
|
||||||
|
cmd.AddCommand(pgpSignCmd())
|
||||||
|
cmd.AddCommand(pgpVerifyCmd())
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func pgpListCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List all imported PGP keys",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
store, err := pgp.NewKeyStore()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create PGP store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, err := store.ListKeys()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list keys: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(keys) == 0 {
|
||||||
|
fmt.Println("No PGP keys imported.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
fmt.Printf("Key ID: %s\n Fingerprint: %s\n Emails: %v\n Trust: %s\n Encrypt: %t Sign: %t\n\n",
|
||||||
|
key.KeyID, key.Fingerprint, key.Emails, key.TrustLevel, key.CanEncrypt, key.CanSign)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pgpImportCmd() *cobra.Command {
|
||||||
|
var trustLevel, filePath string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "import <file>",
|
||||||
|
Short: "Import a PGP key from file",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
filePath = args[0]
|
||||||
|
|
||||||
|
store, err := pgp.NewKeyStore()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create PGP store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := store.ImportKeyFromFile(filePath, trustLevel)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to import key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Imported key:\n ID: %s\n Fingerprint: %s\n Emails: %v\n",
|
||||||
|
key.KeyID, key.Fingerprint, key.Emails)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVar(&trustLevel, "trust", "unknown", "Trust level for the key")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func pgpExportCmd() *cobra.Command {
|
||||||
|
var outputPath string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "export <key_id>",
|
||||||
|
Short: "Export a PGP key",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
keyID := args[0]
|
||||||
|
|
||||||
|
store, err := pgp.NewKeyStore()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create PGP store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if outputPath == "" {
|
||||||
|
outputPath = keyID + ".asc"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.ExportKey(keyID, outputPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to export key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Exported key to: %s\n", outputPath)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&outputPath, "output", "o", "", "Output file path")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func pgpRemoveCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "remove <key_id>",
|
||||||
|
Short: "Remove a PGP key",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
keyID := args[0]
|
||||||
|
|
||||||
|
store, err := pgp.NewKeyStore()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create PGP store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.RemoveKey(keyID); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Removed key: %s\n", keyID)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pgpEncryptCmd() *cobra.Command {
|
||||||
|
var plaintext, keyID string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "encrypt <key_id> --plaintext \"text\"",
|
||||||
|
Short: "Encrypt plaintext with a public key",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if len(args) > 0 {
|
||||||
|
keyID = args[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if plaintext == "" {
|
||||||
|
return fmt.Errorf("plaintext is required (--plaintext)")
|
||||||
|
}
|
||||||
|
if keyID == "" {
|
||||||
|
return fmt.Errorf("key ID is required (positional argument)")
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := pgp.NewKeyStore()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create PGP store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypted, err := store.EncryptData(keyID, plaintext)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(encrypted)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&plaintext, "plaintext", "p", "", "Plaintext to encrypt")
|
||||||
|
cmd.MarkFlagRequired("plaintext")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func pgpDecryptCmd() *cobra.Command {
|
||||||
|
var keyID, passphrase, encryptedData string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "decrypt <key_id>",
|
||||||
|
Short: "Decrypt PGP-encrypted data",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if len(args) > 0 {
|
||||||
|
keyID = args[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := pgp.NewKeyStore()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create PGP store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := store.DecryptData(keyID, encryptedData, passphrase)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to decrypt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(decrypted)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&encryptedData, "encrypted", "e", "", "Encrypted data (armored)")
|
||||||
|
cmd.Flags().StringVarP(&passphrase, "passphrase", "P", "", "Passphrase for private key")
|
||||||
|
cmd.MarkFlagRequired("encrypted")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func pgpSignCmd() *cobra.Command {
|
||||||
|
var plaintext, keyID, passphrase string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "sign <key_id> --plaintext \"text\"",
|
||||||
|
Short: "Sign plaintext with a private key",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if len(args) > 0 {
|
||||||
|
keyID = args[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if plaintext == "" {
|
||||||
|
return fmt.Errorf("plaintext is required (--plaintext)")
|
||||||
|
}
|
||||||
|
if keyID == "" {
|
||||||
|
return fmt.Errorf("key ID is required (positional argument)")
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := pgp.NewKeyStore()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create PGP store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
signature, err := store.SignData(keyID, plaintext, passphrase)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to sign: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(signature)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&plaintext, "plaintext", "p", "", "Plaintext to sign")
|
||||||
|
cmd.Flags().StringVarP(&passphrase, "passphrase", "P", "", "Passphrase for private key")
|
||||||
|
cmd.MarkFlagRequired("plaintext")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func pgpVerifyCmd() *cobra.Command {
|
||||||
|
var keyID, message, signature string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "verify <key_id>",
|
||||||
|
Short: "Verify a detached signature",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if len(args) > 0 {
|
||||||
|
keyID = args[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := pgp.NewKeyStore()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create PGP store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
verified, err := store.VerifySignature(keyID, message, signature)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("verification failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if verified {
|
||||||
|
fmt.Println("Signature is valid.")
|
||||||
|
} else {
|
||||||
|
fmt.Println("Signature is INVALID.")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&message, "message", "m", "", "Message to verify")
|
||||||
|
cmd.Flags().StringVarP(&signature, "signature", "s", "", "Detached signature")
|
||||||
|
cmd.MarkFlagRequired("message")
|
||||||
|
cmd.MarkFlagRequired("signature")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
_ = os.Getenv
|
||||||
|
}
|
||||||
111
cmd/plugin.go
Normal file
111
cmd/plugin.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/frenocorp/pop/internal/plugin"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func pluginCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "plugin",
|
||||||
|
Short: "Manage plugins",
|
||||||
|
Long: `List, enable, disable, and manage plugins for the Pop CLI.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(pluginListCmd())
|
||||||
|
cmd.AddCommand(pluginEnableCmd())
|
||||||
|
cmd.AddCommand(pluginDisableCmd())
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func pluginListCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List all registered plugins",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
reg, err := plugin.NewPluginRegistry()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create plugin registry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins, err := reg.ListPlugins()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list plugins: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(plugins) == 0 {
|
||||||
|
fmt.Println("No plugins registered.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range plugins {
|
||||||
|
fmt.Printf("Name: %s\n Version: %s\n Description: %s\n Binary: %s\n\n",
|
||||||
|
p.Name, p.Version, p.Description, p.Binary)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pluginEnableCmd() *cobra.Command {
|
||||||
|
var name string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "enable <name>",
|
||||||
|
Short: "Enable a plugin",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
name = args[0]
|
||||||
|
|
||||||
|
reg, err := plugin.NewPluginRegistry()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create plugin registry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := reg.EnablePlugin(name); err != nil {
|
||||||
|
return fmt.Errorf("failed to enable plugin: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Enabled plugin: %s\n", name)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func pluginDisableCmd() *cobra.Command {
|
||||||
|
var name string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "disable <name>",
|
||||||
|
Short: "Disable a plugin",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
name = args[0]
|
||||||
|
|
||||||
|
reg, err := plugin.NewPluginRegistry()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create plugin registry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := reg.DisablePlugin(name); err != nil {
|
||||||
|
return fmt.Errorf("failed to disable plugin: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Disabled plugin: %s\n", name)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
_ = os.Getenv
|
||||||
|
}
|
||||||
39
cmd/root.go
39
cmd/root.go
@@ -16,6 +16,34 @@ It provides commands for managing emails, contacts, and attachments
|
|||||||
with full PGP encryption support.`,
|
with full PGP encryption support.`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newRootCmdBase() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "pop",
|
||||||
|
Short: "ProtonMail CLI tool",
|
||||||
|
Long: `pop is a CLI tool for interacting with ProtonMail.
|
||||||
|
|
||||||
|
It provides commands for managing emails, contacts, and attachments
|
||||||
|
with full PGP encryption support.`,
|
||||||
|
}
|
||||||
|
cmd.AddCommand(loginCmd())
|
||||||
|
cmd.AddCommand(logoutCmd())
|
||||||
|
cmd.AddCommand(sessionCmd())
|
||||||
|
cmd.AddCommand(mailCmd())
|
||||||
|
cmd.AddCommand(mailDraftCmd())
|
||||||
|
cmd.AddCommand(contactCmd())
|
||||||
|
cmd.AddCommand(attachmentCmd())
|
||||||
|
cmd.AddCommand(folderCmd())
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
func NewRootCmd() *cobra.Command {
|
func NewRootCmd() *cobra.Command {
|
||||||
rootCmd.AddCommand(loginCmd())
|
rootCmd.AddCommand(loginCmd())
|
||||||
rootCmd.AddCommand(logoutCmd())
|
rootCmd.AddCommand(logoutCmd())
|
||||||
@@ -26,7 +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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
213
cmd/testutil_test.go
Normal file
213
cmd/testutil_test.go
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var stdoutMu sync.Mutex
|
||||||
|
|
||||||
|
// e2eTestEnv provides a self-contained test environment with a temp config dir
|
||||||
|
// and a mock API server. All CLI commands that use config.NewConfigManager() or
|
||||||
|
// auth.NewSessionManager() will resolve to the temp directory.
|
||||||
|
type e2eTestEnv struct {
|
||||||
|
t *testing.T
|
||||||
|
tempDir string
|
||||||
|
mockServer *mockAPIServer
|
||||||
|
origHome string
|
||||||
|
}
|
||||||
|
|
||||||
|
// mockAPIServer wraps httptest.Server with dynamic handler registration.
|
||||||
|
type mockAPIServer struct {
|
||||||
|
mux *http.ServeMux
|
||||||
|
server *httptest.Server
|
||||||
|
handlers sync.Map
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMockAPIServer(t *testing.T) *mockAPIServer {
|
||||||
|
t.Helper()
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
|
||||||
|
ms := &mockAPIServer{mux: mux, server: srv}
|
||||||
|
|
||||||
|
// Register catch-all patterns for all API endpoints used by the CLI
|
||||||
|
mux.HandleFunc("POST /auth", ms.resolve)
|
||||||
|
mux.HandleFunc("POST /auth/verify", ms.resolve)
|
||||||
|
mux.HandleFunc("POST /api/messages", ms.resolve)
|
||||||
|
mux.HandleFunc("POST /api/messages/search", ms.resolve)
|
||||||
|
mux.HandleFunc("POST /api/messages/{id}", ms.resolve)
|
||||||
|
mux.HandleFunc("POST /api/messages/{id}/movetotrash", ms.resolve)
|
||||||
|
mux.HandleFunc("POST /api/messages/{id}/delete", ms.resolve)
|
||||||
|
mux.HandleFunc("POST /api/messages/{id}/send", ms.resolve)
|
||||||
|
mux.HandleFunc("GET /api/folders", ms.resolve)
|
||||||
|
mux.HandleFunc("POST /api/folders", ms.resolve)
|
||||||
|
mux.HandleFunc("POST /api/folders/{id}", ms.resolve)
|
||||||
|
mux.HandleFunc("GET /api/folders/{id}", ms.resolve)
|
||||||
|
mux.HandleFunc("GET /api/labels", ms.resolve)
|
||||||
|
mux.HandleFunc("POST /api/labels", ms.resolve)
|
||||||
|
mux.HandleFunc("POST /api/labels/{id}", ms.resolve)
|
||||||
|
mux.HandleFunc("POST /api/messages/{id}/setlabel", ms.resolve)
|
||||||
|
mux.HandleFunc("POST /api/messages/{id}/clearlabel", ms.resolve)
|
||||||
|
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ms *mockAPIServer) URL() string { return ms.server.URL }
|
||||||
|
func (ms *mockAPIServer) Close() { ms.server.Close() }
|
||||||
|
|
||||||
|
func (ms *mockAPIServer) Handle(key string, handler http.HandlerFunc) {
|
||||||
|
ms.handlers.Store(key, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ms *mockAPIServer) resolve(w http.ResponseWriter, r *http.Request) {
|
||||||
|
key := r.Method + " " + r.URL.Path
|
||||||
|
if h, loaded := ms.handlers.Load(key); loaded {
|
||||||
|
h.(http.HandlerFunc)(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
fmt.Fprintf(w, `{"Code":404,"Message":"not found"}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupE2E creates a fresh test environment. Returns a cleanup func.
|
||||||
|
func setupE2E(t *testing.T) *e2eTestEnv {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create Pop config directory structure
|
||||||
|
popDir := filepath.Join(tempDir, ".config", "pop")
|
||||||
|
if err := os.MkdirAll(popDir, 0700); err != nil {
|
||||||
|
t.Fatalf("create config dir: %v", err)
|
||||||
|
}
|
||||||
|
keyringDir := filepath.Join(popDir, "keyring")
|
||||||
|
if err := os.MkdirAll(keyringDir, 0700); err != nil {
|
||||||
|
t.Fatalf("create keyring dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override HOME so config.NewConfigManager() resolves to our temp dir
|
||||||
|
origHome := os.Getenv("HOME")
|
||||||
|
os.Setenv("HOME", tempDir)
|
||||||
|
t.Cleanup(func() { os.Setenv("HOME", origHome) })
|
||||||
|
|
||||||
|
srv := newMockAPIServer(t)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
return &e2eTestEnv{
|
||||||
|
t: t,
|
||||||
|
tempDir: tempDir,
|
||||||
|
mockServer: srv,
|
||||||
|
origHome: origHome,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeEncryptedSession writes an encrypted session to both the keyring file
|
||||||
|
// and session.json, simulating a successful login.
|
||||||
|
func (env *e2eTestEnv) writeEncryptedSession(uid, accessToken, refreshToken string, expiresAt int64) {
|
||||||
|
sessionData, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"uid": uid,
|
||||||
|
"access_token": accessToken,
|
||||||
|
"refresh_token": refreshToken,
|
||||||
|
"expires_at": expiresAt,
|
||||||
|
"two_factor_enabled": false,
|
||||||
|
"mail_passphrase": "test-passphrase",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Encrypt with AES-256-GCM (same scheme as auth.encryptSession)
|
||||||
|
key := make([]byte, 32)
|
||||||
|
for i := range key {
|
||||||
|
key[i] = byte('k' + i%16)
|
||||||
|
}
|
||||||
|
nonce := make([]byte, 12)
|
||||||
|
for i := range nonce {
|
||||||
|
nonce[i] = byte('n' + i%8)
|
||||||
|
}
|
||||||
|
|
||||||
|
block, _ := aes.NewCipher(key)
|
||||||
|
aead, _ := cipher.NewGCM(block)
|
||||||
|
sealed := aead.Seal(nil, nonce, sessionData, nil)
|
||||||
|
|
||||||
|
encrypted := fmt.Sprintf("%s|%s|%s",
|
||||||
|
base64.StdEncoding.EncodeToString(key),
|
||||||
|
base64.StdEncoding.EncodeToString(nonce),
|
||||||
|
base64.StdEncoding.EncodeToString(sealed),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Write to session.json
|
||||||
|
sessionFile := filepath.Join(env.tempDir, ".config", "pop", "session.json")
|
||||||
|
os.WriteFile(sessionFile, []byte(encrypted), 0600)
|
||||||
|
|
||||||
|
// Write to keyring (file-based keyring stores in keyring/ directory)
|
||||||
|
keyringFile := filepath.Join(env.tempDir, ".config", "pop", "keyring", "session")
|
||||||
|
os.WriteFile(keyringFile, []byte(encrypted), 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonResp writes a JSON response with the given status code.
|
||||||
|
func jsonResp(t *testing.T, w http.ResponseWriter, code int, v interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCommand executes a cobra command with the given args, capturing stdout/stderr.
|
||||||
|
func runCommand(root *cobra.Command, args []string) (string, string, error) {
|
||||||
|
bufOut, bufErr := &bytes.Buffer{}, &bytes.Buffer{}
|
||||||
|
root.SetOut(bufOut)
|
||||||
|
root.SetErr(bufErr)
|
||||||
|
root.SetArgs(args)
|
||||||
|
|
||||||
|
// Disable os.Exit on error by setting SilenceErrors
|
||||||
|
root.SilenceErrors = true
|
||||||
|
err := root.Execute()
|
||||||
|
|
||||||
|
return bufOut.String(), bufErr.String(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// runFreshCommand creates a fresh root command tree and executes the given args.
|
||||||
|
// Captures both cobra output and os.Stdout (since CLI commands use fmt.Printf).
|
||||||
|
func runFreshCommand(args []string) (string, string, error) {
|
||||||
|
stdoutMu.Lock()
|
||||||
|
defer stdoutMu.Unlock()
|
||||||
|
|
||||||
|
root := newRootCmdBase()
|
||||||
|
root.SetArgs(args)
|
||||||
|
root.SilenceErrors = true
|
||||||
|
|
||||||
|
// Capture os.Stdout since CLI commands use fmt.Printf directly
|
||||||
|
origStdout := os.Stdout
|
||||||
|
origStderr := os.Stderr
|
||||||
|
|
||||||
|
rOut, wOut, _ := os.Pipe()
|
||||||
|
os.Stdout = wOut
|
||||||
|
rErr, wErr, _ := os.Pipe()
|
||||||
|
os.Stderr = wErr
|
||||||
|
|
||||||
|
err := root.Execute()
|
||||||
|
|
||||||
|
wOut.Close()
|
||||||
|
wErr.Close()
|
||||||
|
os.Stdout = origStdout
|
||||||
|
os.Stderr = origStderr
|
||||||
|
|
||||||
|
outBytes, _ := io.ReadAll(rOut)
|
||||||
|
errBytes, _ := io.ReadAll(rErr)
|
||||||
|
rOut.Close()
|
||||||
|
rErr.Close()
|
||||||
|
|
||||||
|
return string(outBytes), string(errBytes), err
|
||||||
|
}
|
||||||
278
cmd/thread.go
Normal file
278
cmd/thread.go
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
|
"github.com/frenocorp/pop/internal/api"
|
||||||
|
"github.com/frenocorp/pop/internal/config"
|
||||||
|
internalmail "github.com/frenocorp/pop/internal/mail"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func threadCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "thread",
|
||||||
|
Short: "Manage email conversations",
|
||||||
|
Long: `View, reply to, and manage email conversation threads.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(threadListCmd())
|
||||||
|
cmd.AddCommand(threadShowCmd())
|
||||||
|
cmd.AddCommand(threadReplyCmd())
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func threadListCmd() *cobra.Command {
|
||||||
|
var page, pageSize string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List conversation threads",
|
||||||
|
Long: `List email conversation threads sorted by latest activity.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
pageVal, err := strconv.Atoi(page)
|
||||||
|
if err != nil || pageVal < 1 {
|
||||||
|
pageVal = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pageSizeVal, err := strconv.Atoi(pageSize)
|
||||||
|
if err != nil || pageSizeVal < 1 {
|
||||||
|
pageSizeVal = 20
|
||||||
|
}
|
||||||
|
if pageSizeVal > 100 {
|
||||||
|
pageSizeVal = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgMgr := config.NewConfigManager()
|
||||||
|
cfg, err := cfgMgr.Load()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session, sessionMgr, err := checkAuthenticatedWithManager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := api.NewProtonMailClient(cfg, sessionMgr)
|
||||||
|
client.SetAuthHeader(session.AccessToken)
|
||||||
|
mailClient := internalmail.NewClient(client)
|
||||||
|
|
||||||
|
result, err := mailClient.ListConversations(pageVal, pageSizeVal, session.MailPassphrase)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list conversations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Conversations) == 0 {
|
||||||
|
fmt.Println("No conversations found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return printConversations(result.Conversations)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVar(&page, "page", "1", "Page number")
|
||||||
|
cmd.Flags().StringVar(&pageSize, "page-size", "20", "Conversations per page (max 100)")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func threadShowCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "show <conversation-id>",
|
||||||
|
Short: "Show a conversation thread",
|
||||||
|
Long: `Display all messages in a conversation thread in chronological order.`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
convID := args[0]
|
||||||
|
|
||||||
|
cfgMgr := config.NewConfigManager()
|
||||||
|
cfg, err := cfgMgr.Load()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session, sessionMgr, err := checkAuthenticatedWithManager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := api.NewProtonMailClient(cfg, sessionMgr)
|
||||||
|
client.SetAuthHeader(session.AccessToken)
|
||||||
|
mailClient := internalmail.NewClient(client)
|
||||||
|
|
||||||
|
req := internalmail.GetConversationRequest{
|
||||||
|
ConversationID: convID,
|
||||||
|
Passphrase: session.MailPassphrase,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := mailClient.GetConversation(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get conversation: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Conversation: %s\n", result.Subject)
|
||||||
|
fmt.Printf("Messages: %d\n", result.MessageCount)
|
||||||
|
fmt.Printf("Participants: %s\n", formatRecipients(result.Participants))
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
for i, msg := range result.Messages {
|
||||||
|
from := msg.Sender.DisplayName()
|
||||||
|
date := msg.CreatedAt.Format("2006-01-02 15:04")
|
||||||
|
subject := msg.Subject
|
||||||
|
if len(subject) > 60 {
|
||||||
|
subject = subject[:57] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("--- Message %d ---\n", i+1)
|
||||||
|
fmt.Printf("From: %s\n", from)
|
||||||
|
fmt.Printf("Date: %s\n", date)
|
||||||
|
fmt.Printf("Subject: %s\n", subject)
|
||||||
|
|
||||||
|
if msg.Body != "" {
|
||||||
|
body := msg.Body
|
||||||
|
if len(body) > 200 {
|
||||||
|
body = body[:197] + "..."
|
||||||
|
}
|
||||||
|
fmt.Printf("Body: %s\n", body)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func threadReplyCmd() *cobra.Command {
|
||||||
|
var body, bodyFile, subject string
|
||||||
|
var html bool
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "reply <conversation-id>",
|
||||||
|
Short: "Reply to a conversation",
|
||||||
|
Long: `Reply to the latest message in a conversation thread.`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
convID := args[0]
|
||||||
|
|
||||||
|
cfgMgr := config.NewConfigManager()
|
||||||
|
cfg, err := cfgMgr.Load()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session, sessionMgr, err := checkAuthenticatedWithManager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := api.NewProtonMailClient(cfg, sessionMgr)
|
||||||
|
client.SetAuthHeader(session.AccessToken)
|
||||||
|
mailClient := internalmail.NewClient(client)
|
||||||
|
|
||||||
|
req := internalmail.GetConversationRequest{
|
||||||
|
ConversationID: convID,
|
||||||
|
Passphrase: session.MailPassphrase,
|
||||||
|
}
|
||||||
|
|
||||||
|
conv, err := mailClient.GetConversation(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get conversation: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(conv.Messages) == 0 {
|
||||||
|
return fmt.Errorf("conversation has no messages")
|
||||||
|
}
|
||||||
|
|
||||||
|
latestMsg := conv.Messages[len(conv.Messages)-1]
|
||||||
|
|
||||||
|
var bodyContent string
|
||||||
|
if body != "" {
|
||||||
|
bodyContent = body
|
||||||
|
} else if bodyFile != "" {
|
||||||
|
data, err := os.ReadFile(bodyFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read body file: %w", err)
|
||||||
|
}
|
||||||
|
bodyContent = string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
replySubject := subject
|
||||||
|
if replySubject == "" {
|
||||||
|
replySubject = "Re: " + latestMsg.Subject
|
||||||
|
}
|
||||||
|
|
||||||
|
replyTo := []internalmail.Recipient{latestMsg.Sender.ToRecipient()}
|
||||||
|
|
||||||
|
sendReq := internalmail.SendRequest{
|
||||||
|
To: replyTo,
|
||||||
|
Subject: replySubject,
|
||||||
|
Body: bodyContent,
|
||||||
|
HTML: html,
|
||||||
|
InReplyTo: latestMsg.MimeMessageID,
|
||||||
|
References: latestMsg.ConversationID,
|
||||||
|
Passphrase: session.MailPassphrase,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mailClient.Send(sendReq); err != nil {
|
||||||
|
return fmt.Errorf("failed to send reply: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Reply sent successfully")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&subject, "subject", "s", "", "Reply subject (default: Re: <original>)")
|
||||||
|
cmd.Flags().StringVarP(&bodyFile, "body-file", "f", "", "File containing reply body")
|
||||||
|
cmd.Flags().BoolVar(&html, "html", false, "Send body as HTML")
|
||||||
|
cmd.Flags().StringVar(&body, "body", "", "Inline reply body")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func printConversations(conversations []internalmail.Conversation) error {
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "ID\tSubject\tMessages\tLast From\tLast Date")
|
||||||
|
fmt.Fprintln(w, "--\t-------\t--------\t---------\t---------")
|
||||||
|
|
||||||
|
for _, conv := range conversations {
|
||||||
|
id := conv.ConversationID
|
||||||
|
if len(id) > 12 {
|
||||||
|
id = id[:12]
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := conv.Subject
|
||||||
|
if len(subject) > 50 {
|
||||||
|
subject = subject[:47] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
lastFrom := "-"
|
||||||
|
lastDate := "-"
|
||||||
|
if conv.LastMessage != nil {
|
||||||
|
lastFrom = conv.LastMessage.Sender.DisplayName()
|
||||||
|
lastDate = conv.LastMessage.CreatedAt.Format("2006-01-02 15:04")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s\n", id, subject, conv.MessageCount, lastFrom, lastDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatThreadParticipants(participants []internalmail.Recipient) string {
|
||||||
|
parts := make([]string, len(participants))
|
||||||
|
for i, p := range participants {
|
||||||
|
parts[i] = p.DisplayName()
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
162
cmd/webhook.go
Normal file
162
cmd/webhook.go
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/frenocorp/pop/internal/webhook"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func webhookCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "webhook",
|
||||||
|
Short: "Manage webhooks for ProtonMail",
|
||||||
|
Long: `Add, list, verify, and remove webhooks for real-time ProtonMail notifications.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(webhookListCmd())
|
||||||
|
cmd.AddCommand(webhookAddCmd())
|
||||||
|
cmd.AddCommand(webhookVerifyCmd())
|
||||||
|
cmd.AddCommand(webhookRemoveCmd())
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func webhookListCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List all configured webhooks",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
store, err := webhook.NewWebhookStore()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create webhook store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
webhooks, err := store.ListWebhooks()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list webhooks: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(webhooks) == 0 {
|
||||||
|
fmt.Println("No webhooks configured.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, wh := range webhooks {
|
||||||
|
fmt.Printf("Name: %s\n URL: %s\n ID: %s\n Events: %v\n Active: %t\n Created: %s\n\n",
|
||||||
|
wh.Name, wh.URL, wh.ID, wh.Events, wh.Active, wh.CreatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func webhookAddCmd() *cobra.Command {
|
||||||
|
var name, url string
|
||||||
|
var events []string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "add <name>",
|
||||||
|
Short: "Add a webhook endpoint",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
name = args[0]
|
||||||
|
|
||||||
|
if url == "" {
|
||||||
|
return fmt.Errorf("webhook URL is required (--url)")
|
||||||
|
}
|
||||||
|
if len(events) == 0 {
|
||||||
|
return fmt.Errorf("at least one event type is required (--events)")
|
||||||
|
}
|
||||||
|
|
||||||
|
eventTypes := make([]webhook.EventType, len(events))
|
||||||
|
for i, e := range events {
|
||||||
|
eventTypes[i] = webhook.EventType(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := webhook.NewWebhookStore()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create webhook store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wh, err := store.AddWebhook(name, url, eventTypes, "")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to add webhook: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Webhook added:\n Name: %s\n URL: %s\n ID: %s\n Events: %v\n", wh.Name, wh.URL, wh.ID, wh.Events)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVar(&url, "url", "", "Webhook URL")
|
||||||
|
cmd.Flags().StringArrayVar(&events, "events", []string{"mail.received"}, "Event types (comma-separated, e.g. mail.received,mail.sent)")
|
||||||
|
cmd.MarkFlagRequired("url")
|
||||||
|
cmd.MarkFlagRequired("events")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func webhookVerifyCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "verify",
|
||||||
|
Short: "Verify webhook signatures",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
store, err := webhook.NewWebhookStore()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create webhook store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
webhooks, err := store.ListWebhooks()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list webhooks: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(webhooks) == 0 {
|
||||||
|
fmt.Println("No webhooks to verify.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
testPayload := []byte(`{"test": true}`)
|
||||||
|
for _, wh := range webhooks {
|
||||||
|
valid := webhook.VerifySignature(wh.Secret, testPayload, webhook.ComputeSignature(wh.Secret, testPayload))
|
||||||
|
if valid {
|
||||||
|
fmt.Printf("Webhook %s (%s): signature OK\n", wh.ID, wh.URL)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Webhook %s (%s): signature FAILED\n", wh.ID, wh.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func webhookRemoveCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "remove <id>",
|
||||||
|
Short: "Remove a webhook",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
id := args[0]
|
||||||
|
|
||||||
|
store, err := webhook.NewWebhookStore()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create webhook store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.RemoveWebhook(id); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove webhook: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Removed webhook: %s\n", id)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
_ = os.Getenv
|
||||||
|
}
|
||||||
235
internal/accounts/accounts.go
Normal file
235
internal/accounts/accounts.go
Normal 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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
11
internal/auth/interface.go
Normal file
11
internal/auth/interface.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
37
internal/mail/FRE-4762-verification.md
Normal file
37
internal/mail/FRE-4762-verification.md
Normal 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*
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
1390
internal/mail/client_test.go
Normal file
1390
internal/mail/client_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -25,15 +25,25 @@ func NewPGPService(privateKeyArmored string) (*PGPService, error) {
|
|||||||
return nil, fmt.Errorf("failed to parse private key: %w", err)
|
return nil, fmt.Errorf("failed to parse private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
publicKey, err := privateKey.GetPublicKey()
|
pubKeyBytes, err := privateKey.GetPublicKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to extract public key: %w", err)
|
return nil, fmt.Errorf("failed to extract public key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pubKey, err := crypto.NewKey(pubKeyBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse public key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pubArmor, err := pubKey.Armor()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to armor public key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return &PGPService{
|
return &PGPService{
|
||||||
keyRing: &PGPKeyRing{
|
keyRing: &PGPKeyRing{
|
||||||
PrivateKey: privateKey,
|
PrivateKey: privateKey,
|
||||||
PublicKey: publicKey,
|
PublicKey: []byte(pubArmor),
|
||||||
PrivateKeyData: []byte(privateKeyArmored),
|
PrivateKeyData: []byte(privateKeyArmored),
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
@@ -68,7 +78,7 @@ func (s *PGPService) EncryptBody(plaintext string, passphrase string) (string, e
|
|||||||
return "", fmt.Errorf("failed to get public key: %w", err)
|
return "", fmt.Errorf("failed to get public key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
pubKey, err := crypto.NewKeyFromArmored(string(pubKeyBytes))
|
pubKey, err := crypto.NewKey(pubKeyBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to parse public key: %w", err)
|
return "", fmt.Errorf("failed to parse public key: %w", err)
|
||||||
}
|
}
|
||||||
@@ -131,11 +141,17 @@ func (s *PGPService) getUnlockedKeyRing(passphrase string) (*crypto.KeyRing, err
|
|||||||
}
|
}
|
||||||
|
|
||||||
if passphrase != "" {
|
if passphrase != "" {
|
||||||
unlockedKey, err := key.Unlock([]byte(passphrase))
|
isLocked, err := key.IsLocked()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to unlock private key: %w", err)
|
return nil, fmt.Errorf("failed to check key lock status: %w", err)
|
||||||
|
}
|
||||||
|
if isLocked {
|
||||||
|
unlockedKey, err := key.Unlock([]byte(passphrase))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unlock private key: %w", err)
|
||||||
|
}
|
||||||
|
key = unlockedKey
|
||||||
}
|
}
|
||||||
key = unlockedKey
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return crypto.NewKeyRing(key)
|
return crypto.NewKeyRing(key)
|
||||||
@@ -176,7 +192,15 @@ func (s *PGPService) GenerateKeyPair(email string, passphrase string) (privateKe
|
|||||||
return "", "", fmt.Errorf("failed to extract public key: %w", err)
|
return "", "", fmt.Errorf("failed to extract public key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
pubArmor := string(pubKeyBytes)
|
pubKey, err := crypto.NewKey(pubKeyBytes)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to parse public key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pubArmor, err := pubKey.Armor()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to armor public key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return privateArmor, pubArmor, nil
|
return privateArmor, pubArmor, nil
|
||||||
}
|
}
|
||||||
@@ -229,7 +253,7 @@ func (s *PGPService) EncryptAttachment(data []byte, recipientPublicKey *crypto.K
|
|||||||
|
|
||||||
pgpMessage := crypto.NewPlainMessage(data)
|
pgpMessage := crypto.NewPlainMessage(data)
|
||||||
|
|
||||||
sk, err := crypto.NewSessionKeyFromToken(symKey, "AES256").Encrypt(pgpMessage)
|
sk, err := crypto.NewSessionKeyFromToken(symKey, "aes256").Encrypt(pgpMessage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to encrypt attachment: %w", err)
|
return nil, fmt.Errorf("failed to encrypt attachment: %w", err)
|
||||||
}
|
}
|
||||||
@@ -241,7 +265,7 @@ func (s *PGPService) EncryptAttachment(data []byte, recipientPublicKey *crypto.K
|
|||||||
}
|
}
|
||||||
|
|
||||||
encryptedSymKey, err := recipientKeyRing.EncryptSessionKey(
|
encryptedSymKey, err := recipientKeyRing.EncryptSessionKey(
|
||||||
crypto.NewSessionKeyFromToken(symKey, "AES256"),
|
crypto.NewSessionKeyFromToken(symKey, "aes256"),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to encrypt symmetric key: %w", err)
|
return nil, fmt.Errorf("failed to encrypt symmetric key: %w", err)
|
||||||
|
|||||||
557
internal/mail/pgp_test.go
Normal file
557
internal/mail/pgp_test.go
Normal file
@@ -0,0 +1,557 @@
|
|||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testKey generates a fresh PGP key pair for tests.
|
||||||
|
func testKey(t *testing.T) (privateKey, publicKey, passphrase string) {
|
||||||
|
t.Helper()
|
||||||
|
svc := &PGPService{}
|
||||||
|
privateKey, publicKey, err := svc.GenerateKeyPair("test@example.com", "test-passphrase")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateKeyPair: %v", err)
|
||||||
|
}
|
||||||
|
return privateKey, publicKey, "test-passphrase"
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestService creates a PGPService from a freshly generated key.
|
||||||
|
func newTestService(t *testing.T) (*PGPService, string, string) {
|
||||||
|
t.Helper()
|
||||||
|
priv, pub, pass := testKey(t)
|
||||||
|
svc, err := NewPGPService(priv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPGPService: %v", err)
|
||||||
|
}
|
||||||
|
return svc, pub, pass
|
||||||
|
}
|
||||||
|
|
||||||
|
// newLockedTestService creates a PGPService with a properly passphrase-locked key.
|
||||||
|
// crypto.GenerateKey creates unlocked keys, so we explicitly lock the key after generation.
|
||||||
|
func newLockedTestService(t *testing.T) (*PGPService, string, string) {
|
||||||
|
t.Helper()
|
||||||
|
key, err := crypto.GenerateKey("test@example.com", "", "RSA", 4096)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateKey: %v", err)
|
||||||
|
}
|
||||||
|
lockedKey, err := key.Lock([]byte("test-passphrase"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Lock: %v", err)
|
||||||
|
}
|
||||||
|
privArmor, err := lockedKey.Armor()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Armor: %v", err)
|
||||||
|
}
|
||||||
|
pubKeyBytes, err := lockedKey.GetPublicKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPublicKey: %v", err)
|
||||||
|
}
|
||||||
|
pubKey, err := crypto.NewKey(pubKeyBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewKey: %v", err)
|
||||||
|
}
|
||||||
|
pubArmor, err := pubKey.Armor()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Armor public key: %v", err)
|
||||||
|
}
|
||||||
|
svc, err := NewPGPService(privArmor)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPGPService: %v", err)
|
||||||
|
}
|
||||||
|
return svc, pubArmor, "test-passphrase"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- NewPGPService ----------
|
||||||
|
|
||||||
|
func TestNewPGPService_ValidKey(t *testing.T) {
|
||||||
|
priv, _, _ := testKey(t)
|
||||||
|
svc, err := NewPGPService(priv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPGPService: %v", err)
|
||||||
|
}
|
||||||
|
if svc.keyRing == nil {
|
||||||
|
t.Fatal("keyRing is nil")
|
||||||
|
}
|
||||||
|
if svc.keyRing.PrivateKey == nil {
|
||||||
|
t.Fatal("PrivateKey is nil")
|
||||||
|
}
|
||||||
|
if len(svc.keyRing.PublicKey) == 0 {
|
||||||
|
t.Fatal("PublicKey is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewPGPService_EmptyKey(t *testing.T) {
|
||||||
|
_, err := NewPGPService("")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty key")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "failed to parse private key") {
|
||||||
|
t.Errorf("unexpected error message: %s", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewPGPService_InvalidKey(t *testing.T) {
|
||||||
|
_, err := NewPGPService("NOT A PGP KEY")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- GenerateKeyPair ----------
|
||||||
|
|
||||||
|
func TestGenerateKeyPair_Success(t *testing.T) {
|
||||||
|
svc := &PGPService{}
|
||||||
|
priv, pub, err := svc.GenerateKeyPair("alice@example.com", "pass123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateKeyPair: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(priv, "BEGIN PGP PRIVATE KEY BLOCK") {
|
||||||
|
t.Error("private key missing armored header")
|
||||||
|
}
|
||||||
|
if !strings.Contains(pub, "BEGIN PGP PUBLIC KEY BLOCK") {
|
||||||
|
t.Error("public key missing armored header")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateKeyPair_EmptyEmail(t *testing.T) {
|
||||||
|
svc := &PGPService{}
|
||||||
|
priv, pub, err := svc.GenerateKeyPair("", "pass123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateKeyPair with empty email: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(priv, "BEGIN PGP PRIVATE KEY BLOCK") {
|
||||||
|
t.Error("private key missing armored header")
|
||||||
|
}
|
||||||
|
if !strings.Contains(pub, "BEGIN PGP PUBLIC KEY BLOCK") {
|
||||||
|
t.Error("public key missing armored header")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- GetFingerprint ----------
|
||||||
|
|
||||||
|
func TestGetFingerprint_Success(t *testing.T) {
|
||||||
|
svc, _, _ := newTestService(t)
|
||||||
|
fp, err := svc.GetFingerprint()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetFingerprint: %v", err)
|
||||||
|
}
|
||||||
|
if len(fp) != 40 {
|
||||||
|
t.Errorf("expected 40-char fingerprint, got %d", len(fp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetFingerprint_NoKeyRing(t *testing.T) {
|
||||||
|
svc := &PGPService{}
|
||||||
|
_, err := svc.GetFingerprint()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nil keyRing")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "no key ring available") {
|
||||||
|
t.Errorf("unexpected error: %s", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- ZeroPrivateKeyData ----------
|
||||||
|
|
||||||
|
func TestZeroPrivateKeyData_Success(t *testing.T) {
|
||||||
|
svc, _, _ := newTestService(t)
|
||||||
|
initialLen := len(svc.keyRing.PrivateKeyData)
|
||||||
|
svc.ZeroPrivateKeyData()
|
||||||
|
for i, b := range svc.keyRing.PrivateKeyData {
|
||||||
|
if b != 0 {
|
||||||
|
t.Errorf("byte %d not zeroed: %d", i, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(svc.keyRing.PrivateKeyData) != initialLen {
|
||||||
|
t.Error("PrivateKeyData length changed after zeroing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZeroPrivateKeyData_NilKeyRing(t *testing.T) {
|
||||||
|
svc := &PGPService{}
|
||||||
|
svc.ZeroPrivateKeyData() // should not panic
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Encrypt / Decrypt roundtrip ----------
|
||||||
|
|
||||||
|
func TestEncryptDecrypt_Roundtrip(t *testing.T) {
|
||||||
|
svc, pubArmor, pass := newTestService(t)
|
||||||
|
|
||||||
|
recipientKey, err := crypto.NewKeyFromArmored(pubArmor)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse recipient key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext := "Hello, encrypted world!"
|
||||||
|
encrypted, err := svc.Encrypt(plaintext, recipientKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encrypt: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(encrypted, "BEGIN PGP MESSAGE") {
|
||||||
|
t.Error("encrypted output missing PGP message header")
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := svc.Decrypt(encrypted, pass)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decrypt: %v", err)
|
||||||
|
}
|
||||||
|
if decrypted != plaintext {
|
||||||
|
t.Errorf("roundtrip mismatch: got %q, want %q", decrypted, plaintext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptDecrypt_LargePayload(t *testing.T) {
|
||||||
|
svc, pubArmor, pass := newTestService(t)
|
||||||
|
|
||||||
|
recipientKey, err := crypto.NewKeyFromArmored(pubArmor)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse recipient key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := strings.Repeat("ABCDEFGHijklmnop12345678\n", 100)
|
||||||
|
encrypted, err := svc.Encrypt(payload, recipientKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encrypt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := svc.Decrypt(encrypted, pass)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decrypt: %v", err)
|
||||||
|
}
|
||||||
|
if decrypted != payload {
|
||||||
|
t.Errorf("large payload roundtrip mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecrypt_InvalidMessage(t *testing.T) {
|
||||||
|
svc, _, _ := newTestService(t)
|
||||||
|
_, err := svc.Decrypt("NOT A PGP MESSAGE", "test-passphrase")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecrypt_WrongPassphrase(t *testing.T) {
|
||||||
|
svc, pubArmor, _ := newLockedTestService(t)
|
||||||
|
|
||||||
|
recipientKey, err := crypto.NewKeyFromArmored(pubArmor)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse recipient key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypted, err := svc.Encrypt("secret", recipientKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encrypt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = svc.Decrypt(encrypted, "wrong-passphrase")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for wrong passphrase")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- EncryptBody ----------
|
||||||
|
|
||||||
|
func TestEncryptBody_Success(t *testing.T) {
|
||||||
|
svc, _, pass := newTestService(t)
|
||||||
|
|
||||||
|
plaintext := "Body content to encrypt"
|
||||||
|
encrypted, err := svc.EncryptBody(plaintext, pass)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncryptBody: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(encrypted, "BEGIN PGP MESSAGE") {
|
||||||
|
t.Error("encrypted body missing PGP message header")
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := svc.Decrypt(encrypted, pass)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decrypt: %v", err)
|
||||||
|
}
|
||||||
|
if decrypted != plaintext {
|
||||||
|
t.Errorf("EncryptBody roundtrip mismatch: got %q, want %q", decrypted, plaintext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptBody_WrongPassphrase(t *testing.T) {
|
||||||
|
svc, _, _ := newLockedTestService(t)
|
||||||
|
|
||||||
|
_, err := svc.EncryptBody("content", "wrong-passphrase")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for wrong passphrase")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- EncryptAndSign ----------
|
||||||
|
|
||||||
|
func TestEncryptAndSign_Success(t *testing.T) {
|
||||||
|
svc, pubArmor, pass := newTestService(t)
|
||||||
|
|
||||||
|
recipientKey, err := crypto.NewKeyFromArmored(pubArmor)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse recipient key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext := "Signed and encrypted content"
|
||||||
|
encrypted, err := svc.EncryptAndSign(plaintext, recipientKey, pass)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncryptAndSign: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(encrypted, "BEGIN PGP MESSAGE") {
|
||||||
|
t.Error("encrypted+signed output missing PGP message header")
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := svc.Decrypt(encrypted, pass)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decrypt: %v", err)
|
||||||
|
}
|
||||||
|
if decrypted != plaintext {
|
||||||
|
t.Errorf("EncryptAndSign roundtrip mismatch: got %q, want %q", decrypted, plaintext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptAndSign_WrongPassphrase(t *testing.T) {
|
||||||
|
svc, pubArmor, _ := newLockedTestService(t)
|
||||||
|
|
||||||
|
recipientKey, err := crypto.NewKeyFromArmored(pubArmor)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse recipient key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = svc.EncryptAndSign("content", recipientKey, "wrong-passphrase")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for wrong passphrase")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- SignData ----------
|
||||||
|
|
||||||
|
func TestSignData_Success(t *testing.T) {
|
||||||
|
svc, _, pass := newTestService(t)
|
||||||
|
|
||||||
|
data := []byte("Data to be signed")
|
||||||
|
signed, err := svc.SignData(data, pass)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SignData: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(signed, "BEGIN PGP SIGNATURE") {
|
||||||
|
t.Error("signed output missing PGP signature header")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignData_WrongPassphrase(t *testing.T) {
|
||||||
|
svc, _, _ := newLockedTestService(t)
|
||||||
|
|
||||||
|
_, err := svc.SignData([]byte("data"), "wrong-passphrase")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for wrong passphrase")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignData_EmptyData(t *testing.T) {
|
||||||
|
svc, _, pass := newTestService(t)
|
||||||
|
|
||||||
|
signed, err := svc.SignData([]byte(""), pass)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SignData empty: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(signed, "BEGIN PGP SIGNATURE") {
|
||||||
|
t.Error("empty data signature missing PGP signature header")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- EncryptAttachment / DecryptAttachment ----------
|
||||||
|
|
||||||
|
func TestEncryptDecryptAttachment_Roundtrip(t *testing.T) {
|
||||||
|
svc, pubArmor, pass := newTestService(t)
|
||||||
|
|
||||||
|
recipientKey, err := crypto.NewKeyFromArmored(pubArmor)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse recipient key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
original := []byte("Attachment binary content")
|
||||||
|
attachment, err := svc.EncryptAttachment(original, recipientKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncryptAttachment: %v", err)
|
||||||
|
}
|
||||||
|
if attachment == nil {
|
||||||
|
t.Fatal("attachment is nil")
|
||||||
|
}
|
||||||
|
if attachment.DataEnc == "" {
|
||||||
|
t.Error("DataEnc is empty")
|
||||||
|
}
|
||||||
|
if len(attachment.Keys) == 0 {
|
||||||
|
t.Error("Keys slice is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := svc.DecryptAttachment(attachment, pass)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecryptAttachment: %v", err)
|
||||||
|
}
|
||||||
|
if string(decrypted) != string(original) {
|
||||||
|
t.Errorf("attachment roundtrip mismatch: got %q, want %q", string(decrypted), string(original))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptDecryptAttachment_LargeData(t *testing.T) {
|
||||||
|
svc, pubArmor, pass := newTestService(t)
|
||||||
|
|
||||||
|
recipientKey, err := crypto.NewKeyFromArmored(pubArmor)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse recipient key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
original := make([]byte, 10240)
|
||||||
|
for i := range original {
|
||||||
|
original[i] = byte(i % 256)
|
||||||
|
}
|
||||||
|
|
||||||
|
attachment, err := svc.EncryptAttachment(original, recipientKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncryptAttachment: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := svc.DecryptAttachment(attachment, pass)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecryptAttachment: %v", err)
|
||||||
|
}
|
||||||
|
if len(decrypted) != len(original) {
|
||||||
|
t.Errorf("size mismatch: got %d, want %d", len(decrypted), len(original))
|
||||||
|
}
|
||||||
|
for i := range original {
|
||||||
|
if decrypted[i] != original[i] {
|
||||||
|
t.Errorf("byte %d mismatch: got %d, want %d", i, decrypted[i], original[i])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecryptAttachment_NoKeys(t *testing.T) {
|
||||||
|
svc, _, pass := newTestService(t)
|
||||||
|
|
||||||
|
attachment := &Attachment{DataEnc: "some-data"}
|
||||||
|
_, err := svc.DecryptAttachment(attachment, pass)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for attachment with no keys")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "no keys available") {
|
||||||
|
t.Errorf("unexpected error: %s", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecryptAttachment_WrongPassphrase(t *testing.T) {
|
||||||
|
svc, pubArmor, _ := newLockedTestService(t)
|
||||||
|
|
||||||
|
recipientKey, err := crypto.NewKeyFromArmored(pubArmor)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse recipient key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
attachment, err := svc.EncryptAttachment([]byte("content"), recipientKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncryptAttachment: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = svc.DecryptAttachment(attachment, "wrong-passphrase")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for wrong passphrase")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Cross-key Encrypt/Decrypt ----------
|
||||||
|
|
||||||
|
func TestEncryptDecrypt_CrossKey(t *testing.T) {
|
||||||
|
sender, senderPub, senderPass := newTestService(t)
|
||||||
|
_, _, _ = sender, senderPub, senderPass
|
||||||
|
|
||||||
|
recipientPriv, recipientPub, _ := testKey(t)
|
||||||
|
recipientKey, err := crypto.NewKeyFromArmored(recipientPub)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse recipient pub key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext := "Cross-key encrypted message"
|
||||||
|
encrypted, err := sender.Encrypt(plaintext, recipientKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encrypt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
recipientSVC, err := NewPGPService(recipientPriv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPGPService for recipient: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := recipientSVC.Decrypt(encrypted, "test-passphrase")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decrypt: %v", err)
|
||||||
|
}
|
||||||
|
if decrypted != plaintext {
|
||||||
|
t.Errorf("cross-key roundtrip mismatch: got %q, want %q", decrypted, plaintext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- EncryptAndSign with cross-key ----------
|
||||||
|
|
||||||
|
func TestEncryptAndSign_CrossKey(t *testing.T) {
|
||||||
|
sender, _, senderPass := newTestService(t)
|
||||||
|
|
||||||
|
recipientPriv, recipientPub, _ := testKey(t)
|
||||||
|
recipientKey, err := crypto.NewKeyFromArmored(recipientPub)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse recipient pub key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext := "Cross-key signed+encrypted"
|
||||||
|
encrypted, err := sender.EncryptAndSign(plaintext, recipientKey, senderPass)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncryptAndSign: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
recipientSVC, err := NewPGPService(recipientPriv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPGPService for recipient: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := recipientSVC.Decrypt(encrypted, "test-passphrase")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decrypt: %v", err)
|
||||||
|
}
|
||||||
|
if decrypted != plaintext {
|
||||||
|
t.Errorf("cross-key EncryptAndSign mismatch: got %q, want %q", decrypted, plaintext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Attachment cross-key ----------
|
||||||
|
|
||||||
|
func TestEncryptDecryptAttachment_CrossKey(t *testing.T) {
|
||||||
|
sender, _, _ := newTestService(t)
|
||||||
|
|
||||||
|
recipientPriv, recipientPub, _ := testKey(t)
|
||||||
|
recipientKey, err := crypto.NewKeyFromArmored(recipientPub)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse recipient pub key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
original := []byte("Cross-key attachment data")
|
||||||
|
attachment, err := sender.EncryptAttachment(original, recipientKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncryptAttachment: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
recipientSVC, err := NewPGPService(recipientPriv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPGPService for recipient: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := recipientSVC.DecryptAttachment(attachment, "test-passphrase")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecryptAttachment: %v", err)
|
||||||
|
}
|
||||||
|
if string(decrypted) != string(original) {
|
||||||
|
t.Errorf("cross-key attachment mismatch: got %q, want %q", string(decrypted), string(original))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
465
internal/pgp/pgp.go
Normal 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
291
internal/plugin/plugin.go
Normal 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
375
internal/webhook/webhook.go
Normal 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
|
||||||
|
}
|
||||||
28
tests/README.md
Normal file
28
tests/README.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Test Utilities
|
||||||
|
|
||||||
|
This directory contains integration test utilities and helpers for the Pop CLI.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
- `integration_test.go` - Integration test suite
|
||||||
|
- `fixtures/` - Test fixtures and test data
|
||||||
|
- `helpers/` - Test helper functions
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests including integration tests
|
||||||
|
go test -v ./...
|
||||||
|
|
||||||
|
# Run only integration tests
|
||||||
|
go test -v ./tests/...
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
go test -v -coverprofile=coverage.out ./tests/...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coverage Requirements
|
||||||
|
|
||||||
|
- Minimum 80% coverage required for CI to pass
|
||||||
|
- Integration tests should cover end-to-end workflows
|
||||||
|
- Unit tests should cover individual components
|
||||||
22
tests/fixtures/test-config.yaml
vendored
Normal file
22
tests/fixtures/test-config.yaml
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Test configuration for Pop CLI integration tests
|
||||||
|
|
||||||
|
app:
|
||||||
|
name: "Pop Test"
|
||||||
|
version: "1.0.0-test"
|
||||||
|
|
||||||
|
api:
|
||||||
|
base_url: "http://localhost:8080"
|
||||||
|
timeout: 30s
|
||||||
|
retry_count: 3
|
||||||
|
|
||||||
|
database:
|
||||||
|
driver: "sqlite"
|
||||||
|
path: ":memory:"
|
||||||
|
|
||||||
|
mail:
|
||||||
|
provider: "test"
|
||||||
|
from_address: "test@frenocorp.com"
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: "debug"
|
||||||
|
format: "json"
|
||||||
42
tests/integration_test.go
Normal file
42
tests/integration_test.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMain is the entry point for integration tests
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
// Setup integration test environment
|
||||||
|
setupIntegrationEnv()
|
||||||
|
|
||||||
|
// Run tests
|
||||||
|
code := m.Run()
|
||||||
|
|
||||||
|
// Teardown
|
||||||
|
teardownIntegrationEnv()
|
||||||
|
|
||||||
|
os.Exit(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupIntegrationEnv prepares the test environment
|
||||||
|
func setupIntegrationEnv() {
|
||||||
|
// Set test environment variables
|
||||||
|
os.Setenv("POP_TEST_MODE", "true")
|
||||||
|
os.Setenv("POP_CONFIG_PATH", "./fixtures/test-config.yaml")
|
||||||
|
}
|
||||||
|
|
||||||
|
// teardownIntegrationEnv cleans up the test environment
|
||||||
|
func teardownIntegrationEnv() {
|
||||||
|
os.Unsetenv("POP_TEST_MODE")
|
||||||
|
os.Unsetenv("POP_CONFIG_PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVersion verifies the CLI version command works
|
||||||
|
func TestVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// This is a placeholder test - actual implementation would invoke the CLI
|
||||||
|
// and verify the version output
|
||||||
|
t.Log("Integration test suite initialized")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user