Compare commits

...

20 Commits

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

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

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

Build and tests pass clean.

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

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

All compilation errors fixed and build verified CLEAN.

Security review delegated to FRE-5202.

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

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

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

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

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

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

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-09 21:46:03 -04:00
19a9e2a3df Fix test parallelism in e2e tests
Some checks failed
CI / build (1.21.x) (push) Has been cancelled
CI / build (1.22.x) (push) Has been cancelled
CI / security-scan (push) Has been cancelled
- Removed t.Parallel() from e2e tests that share global state
- Tests now run sequentially to avoid conflicts with cobra command initialization
- Ensures reliable test execution without race conditions
2026-05-04 02:08:02 -04:00
d53b8ec8bc FRE-4694: Add CLI command end-to-end tests
Add comprehensive e2e tests for all CLI commands with mocked API
responses. Fix test infrastructure to handle global state (os.Stdout
capture, HOME env var) and broken test parallelism in stdout-capturing
tests.

- Add testutil_test.go with runFreshCommand, setupE2E, mockAPIServer
- Add e2e_full_test.go with ~40 tests covering auth, mail, contacts,
  attachments, folders, labels, drafts, help output
- Add newRootCmdBase() for fresh command trees per test
- Remove t.Parallel() from stdout-capturing and HOME-dependent tests
- Fix SessionWithMockSession to use runFreshCommand (stdout capture)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-04 01:08:26 -04:00
a78c564e23 Add CI test stage with coverage reporting and test infrastructure
Some checks failed
CI / build (1.21.x) (push) Has been cancelled
CI / build (1.22.x) (push) Has been cancelled
CI / security-scan (push) Has been cancelled
- Updated .github/workflows/ci.yml to include:
  - Go module caching for faster builds
  - Coverage report generation and upload to Codecov
  - 80% coverage threshold check
- Created tests/ directory with integration test framework
- Added test fixtures and configuration
- Initial integration test passes

Related: FRE-8b42289c (Pop: Add CI test stage to workflow)
2026-05-03 20:38:29 -04:00
ced8204ef8 Add unit tests for PGP service (FRE-4692)
- 27 new tests covering all PGP service methods
- Fixes: armored public key in NewPGPService/GenerateKeyPair/EncryptBody,
  IsLocked check in getUnlockedKeyRing, aes256 cipher token in EncryptAttachment

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-03 19:21:18 -04:00
Senior Engineer
90bee9119e FRE-4693: Add integration tests for mail client
46 new integration tests covering all mail client operations:
- ListMessages: success, folder/starred/read/since filters, combined filters, API errors, bad JSON
- GetMessage: success, URL escaping, not found, decrypt body with PGP
- Send: success, PGP encryption, CC/BCC, HTTP errors, 201 status
- MoveToTrash: success, error handling
- PermanentlyDelete: success, error, URL escaping
- SaveDraft: success, CC/BCC, API error, bad JSON
- UpdateDraft: success, CC, conflict error
- SendDraft: success, error
- ListDrafts: success via ListMessages delegation
- SearchMessages: success, empty results, API error, bad JSON
- Auth header propagation, content-type headers
- Concurrent access safety, timeout handling
- Helper types: Folder.Name, Recipient.DisplayName, Message.Folder

Uses httptest.Server with configurable route handlers to mock ProtonMail API.
All 46 tests pass. 3 pre-existing pgp_test.go failures unchanged.
2026-05-03 17:34:27 -04:00
6cc520e221 docs
Some checks failed
CI / build (1.21.x) (push) Has been cancelled
CI / build (1.22.x) (push) Has been cancelled
CI / security-scan (push) Has been cancelled
2026-04-29 16:30:29 -04:00
Paperclip
0684e726bb FRE-681: Fix security review findings (3 HIGH, 3 MEDIUM, 2 LOW)
HIGH fixes:
- Access Token now used as PGP Passphrase: replaced session.AccessToken
  with session.MailPassphrase for all PGP operations
- Session stored encrypted in keyring and file (was plain JSON)
- Added checkAuthenticated() helper with IsAuthenticated() guard

MEDIUM fixes:
- Added MailPassphrase field to Session, collected during login
- Added email validation in LoginInteractive
- Added keyring cleanup on Logout
- Implemented RefreshToken with actual API call

LOW fixes:
- Added mutex to PGPKeyRing for thread safety
- Added ZeroPrivateKeyData() for memory cleanup
- Use net/mail.ParseAddress for proper recipient parsing
- Renamed internal/mail import to internalmail to avoid conflict
2026-04-28 12:40:09 -04:00
Senior Engineer
e499d16b7c FRE-681: Fix code review findings - body flag, PGP encryption, passphrase handling
- cmd/mail.go: Fix duplicate --body/--body-file flag binding (both used bodyFile)
- internal/mail/client.go: Add PGP encryption to Send via EncryptBody, add passphrase to MoveToTrash and SendDraft
- internal/mail/pgp.go: Store armored private key, add getUnlockedKeyRing helper,
  fix Decrypt/SignData/EncryptAndSign/DecryptAttachment to use passphrase via key.Unlock
- internal/mail/pgp.go: Add EncryptBody method for Send encryption with sender key
- cmd/draft.go: Update SendDraft call to include passphrase parameter
2026-04-28 10:08:33 -04:00
Paperclip
af25fd5575 FRE-682: Add folder/label management, search, and fix PGP build
- Add pop mail search CLI command with pagination support
- Create internal/labels package with types and API client
- Add folder list/create/update/delete CLI commands
- Add label list/create/update/delete/apply/remove CLI commands
- Register folder and label commands in root.go
- Fix gopenpgp v2 API mismatches in pgp.go (NewPlainMessage, Armor,
  KeyRing.Encrypt/Decrypt, SessionKey)
- Fix NewSessionManager error handling across cmd files
- Fix variable shadowing bug in mail/client.go
2026-04-28 06:37:47 -04:00
35d47733ea Auto-commit 2026-04-27 19:13 2026-04-27 19:13:03 -04:00
45 changed files with 11544 additions and 321 deletions

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
go-version: [1.21.x, 1.22.x] go-version: [1.23.x]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -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

175
FRE-683-SECURITY-FIXES.md Normal file
View File

@@ -0,0 +1,175 @@
# Security Fixes Applied to FRE-683
**Date**: 2026-04-27
**Status**: HIGH & MEDIUM priority fixes completed
## Summary
All security issues identified in the Security Review have been addressed:
### HIGH Priority Fixes ✅
#### 1. Path Traversal Vulnerability (CVE-class)
**File**: `internal/attachment/manager.go`
**Changes**:
- Added `isAttachmentIDSafe()` function to validate attachmentID contains only safe characters (alphanumeric, hyphen, underscore)
- Added `sanitizeAttachmentID()` function that:
- Validates attachmentID using `isAttachmentIDSafe()`
- Uses `filepath.Clean()` to resolve any `..` or `.` components
- Rejects any ID that resolves differently after cleaning
- All attachment operations now call `sanitizeAttachmentID()` before use:
- `Download()`
- `Upload()`
- `Get()`
- `Delete()`
**Code Example**:
```go
func isAttachmentIDSafe(id string) bool {
if id == "" {
return false
}
// Only allow alphanumeric, hyphen, and underscore
for _, r := range id {
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '-' || r == '_') {
return false
}
}
return true
}
```
#### 2. No File Size Limit (DoS)
**File**: `internal/attachment/manager.go`
**Changes**:
- Added `maxUploadSize = 50 * 1024 * 1024` constant (50MB)
- Modified `Upload()` to use `io.LimitReader()` before reading
- Prevents memory exhaustion from large uploads
**Code Example**:
```go
const maxUploadSize = 50 * 1024 * 1024 // 50MB
func (m *AttachmentManager) Upload(attachmentID, name string, reader io.Reader) error {
// Sanitize attachmentID to prevent path traversal
if sanitizedID, err := sanitizeAttachmentID(attachmentID); err != nil {
return err
} else {
attachmentID = sanitizedID
}
if err := os.MkdirAll(m.attachmentsDir, 0755); err != nil {
return err
}
// Limit reader to maxUploadSize to prevent DoS
limitedReader := io.LimitReader(reader, maxUploadSize)
data, err := io.ReadAll(limitedReader)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(m.attachmentsDir, attachmentID), data, 0644)
}
```
### MEDIUM Priority Fixes ✅
#### 3. Contact Edit Overwrites
**File**: `cmd/contacts.go`
**Changes**:
- Modified `contactEditCmd()` to check `cmd.Flags().Changed()` before setting pointer fields
- Prevents overwriting existing values when flags are not explicitly provided
**Before** (Buggy):
```go
req := contact.UpdateContactRequest{
Name: &name,
Phone: &phone,
Address: &address,
Notes: &notes,
}
```
**After** (Fixed):
```go
req := contact.UpdateContactRequest{}
if cmd.Flags().Changed("name") {
req.Name = &name
}
if cmd.Flags().Changed("phone") {
req.Phone = &phone
}
if cmd.Flags().Changed("address") {
req.Address = &address
}
if cmd.Flags().Changed("notes") {
req.Notes = &notes
}
```
#### 4. No Concurrency Protection
**File**: `internal/contact/manager.go`
**Changes**:
- Added `sync.Mutex` field to `ContactManager` struct
- Wrapped all CRUD operations with `m.mu.Lock()` / `defer m.mu.Unlock()`:
- `List()`
- `Create()`
- `Get()`
- `Update()`
- `Delete()`
**Code Example**:
```go
type ContactManager struct {
mu sync.Mutex
configDir string
contactsFile string
}
func (m *ContactManager) List(req ListContactsRequest) (*ListContactsResponse, error) {
m.mu.Lock()
defer m.mu.Unlock()
contacts, err := m.loadContacts()
if err != nil {
return nil, err
}
// ... rest of function
}
```
### Pending / Not Required ✅
#### 5. Inconsistent Path Resolution
**Status**: Not fixed - this is a minor consistency issue, not a security vulnerability
**Analysis**:
- `internal/attachment/manager.go` uses `os.Getenv("HOME")` - acceptable for local tool
- `internal/contact/manager.go` uses `config.NewConfigManager().ConfigDir()` - recommended approach
- Both resolve to the same location (`~/.config/pop/`)
- This is a code style preference, not a bug
---
## Verification
All changes have been applied to the source files. The Code Reviewer should:
1. Review the security fixes in `internal/attachment/manager.go`
2. Review the concurrency fixes in `internal/contact/manager.go`
3. Review the edit command fix in `cmd/contacts.go`
4. Approve for merge once verified
---
## Next Steps
1. ✅ Security fixes applied
2. ⏳ Assign to Code Reviewer for re-review
3. ⏳ Re-assign to Founding Engineer upon approval
4. ⏳ Merge and deploy

287
README.md
View File

@@ -4,10 +4,18 @@ A ProtonMail CLI tool written in Go, similar to gog.
## Features ## Features
- **Authentication**: Login/logout with 2FA support - **Authentication**: Interactive login/logout with 2FA support and masked password prompts
- **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,12 +31,11 @@ make install
## Usage ## Usage
```bash ### Authentication
# Initialize login (interactive mode)
pop login
# Login with explicit credentials ```bash
pop login --email user@proton.me --password secret # Initialize login (interactive mode with masked password prompt)
pop login
# Check current session # Check current session
pop session pop session
@@ -37,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
@@ -86,6 +281,80 @@ Session data is stored in `~/.config/pop/session.json`:
} }
``` ```
### Multi-Account Configuration
Accounts are stored in `~/.config/pop/accounts.json`:
```json
[
{
"name": "work",
"email": "work@example.com",
"api_base_url": "https://api.protonmail.ch",
"default": true,
"created_at": "2024-01-01T00:00:00Z"
}
]
```
### Webhook Configuration
Webhooks are stored in `~/.config/pop/webhooks.json`:
```json
[
{
"id": "wh_1234567890",
"name": "notifications",
"url": "https://example.com/webhook",
"events": ["mail.received", "mail.sent"],
"secret": "abc123...",
"active": true,
"created_at": "2024-01-01T00:00:00Z"
}
]
```
### PGP Key Configuration
PGP keys are stored in `~/.config/pop/pgp_keys.json` with key files in `~/.config/pop/pgp_keys/`:
```json
[
{
"key_id": "ABCD1234",
"fingerprint": "ABCD1234567890ABCD1234567890ABCD1234",
"emails": ["user@example.com"],
"trust_level": "full",
"can_encrypt": true,
"can_sign": true,
"armor_file": "/home/user/.config/pop/pgp_keys/ABCD1234.asc"
}
]
```
### Plugin Configuration
Plugins are stored in `~/.config/pop/plugins.json` with binaries in `~/.config/pop/plugins/`:
```json
[
{
"name": "myplugin",
"version": "1.0.0",
"description": "My custom plugin",
"binary": "/home/user/.config/pop/plugins/pop-myplugin",
"enabled": true,
"commands": [
{
"name": "mycommand",
"description": "My custom command"
}
]
}
]
```
## Development ## Development
```bash ```bash

210
cmd/accounts.go Normal file
View File

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

View File

@@ -1,10 +1,12 @@
package cmd package cmd
import ( import (
"context"
"fmt" "fmt"
"os" "os"
"github.com/frenocorp/pop/internal/auth" "github.com/frenocorp/pop/internal/auth"
"github.com/frenocorp/pop/internal/config"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -14,14 +16,20 @@ func loginCmd() *cobra.Command {
Short: "Log in to ProtonMail", Short: "Log in to ProtonMail",
Long: `Authenticate with ProtonMail API and store session credentials.`, Long: `Authenticate with ProtonMail API and store session credentials.`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
manager := auth.NewSessionManager() cfgMgr := config.NewConfigManager()
return manager.Login() cfg, err := cfgMgr.Load()
}, if err != nil {
return fmt.Errorf("failed to load config: %w", err)
} }
cmd.Flags().StringP("email", "e", "", "ProtonMail email address") manager, err := auth.NewSessionManager()
cmd.Flags().StringP("password", "p", "", "ProtonMail password") if err != nil {
cmd.Flags().BoolP("interactive", "i", true, "Interactive prompt for credentials") return fmt.Errorf("failed to create session manager: %w", err)
}
return manager.LoginInteractive(context.Background(), cfg.APIBaseURL)
},
}
return cmd return cmd
} }
@@ -32,7 +40,10 @@ func logoutCmd() *cobra.Command {
Short: "Log out from ProtonMail", Short: "Log out from ProtonMail",
Long: `Clear stored session credentials.`, Long: `Clear stored session credentials.`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
manager := auth.NewSessionManager() manager, err := auth.NewSessionManager()
if err != nil {
return fmt.Errorf("failed to create session manager: %w", err)
}
return manager.Logout() return manager.Logout()
}, },
} }
@@ -46,7 +57,10 @@ func sessionCmd() *cobra.Command {
Short: "Show current session info", Short: "Show current session info",
Long: `Display current authentication session details.`, Long: `Display current authentication session details.`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
manager := auth.NewSessionManager() manager, err := auth.NewSessionManager()
if err != nil {
return fmt.Errorf("failed to create session manager: %w", err)
}
session, err := manager.GetSession() session, err := manager.GetSession()
if err != nil { if err != nil {
return fmt.Errorf("no active session: %w", err) return fmt.Errorf("no active session: %w", err)

152
cmd/auth_test.go Normal file
View 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
View File

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

View File

@@ -123,11 +123,20 @@ func contactEditCmd() *cobra.Command {
address, _ := cmd.Flags().GetString("address") address, _ := cmd.Flags().GetString("address")
notes, _ := cmd.Flags().GetString("notes") notes, _ := cmd.Flags().GetString("notes")
req := contact.UpdateContactRequest{ req := contact.UpdateContactRequest{}
Name: &name,
Phone: &phone, // Only set pointer fields when the flag was explicitly changed
Address: &address, if cmd.Flags().Changed("name") {
Notes: &notes, req.Name = &name
}
if cmd.Flags().Changed("phone") {
req.Phone = &phone
}
if cmd.Flags().Changed("address") {
req.Address = &address
}
if cmd.Flags().Changed("notes") {
req.Notes = &notes
} }
updated, err := manager.Update(id, req) updated, err := manager.Update(id, req)

View File

@@ -6,9 +6,8 @@ import (
"strconv" "strconv"
"github.com/frenocorp/pop/internal/api" "github.com/frenocorp/pop/internal/api"
"github.com/frenocorp/pop/internal/auth"
"github.com/frenocorp/pop/internal/config" "github.com/frenocorp/pop/internal/config"
"github.com/frenocorp/pop/internal/mail" internalmail "github.com/frenocorp/pop/internal/mail"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -49,12 +48,12 @@ func draftSaveCmd() *cobra.Command {
} }
recipients := parseRecipients(to) recipients := parseRecipients(to)
var ccRecipients []mail.Recipient var ccRecipients []internalmail.Recipient
if cc != "" { if cc != "" {
ccRecipients = parseRecipients(cc) ccRecipients = parseRecipients(cc)
} }
var bccRecipients []mail.Recipient var bccRecipients []internalmail.Recipient
if bcc != "" { if bcc != "" {
bccRecipients = parseRecipients(bcc) bccRecipients = parseRecipients(bcc)
} }
@@ -65,17 +64,16 @@ func draftSaveCmd() *cobra.Command {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
sessionMgr := auth.NewSessionManager() session, sessionMgr, err := checkAuthenticatedWithManager()
session, err := sessionMgr.GetSession()
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 := mail.NewClient(client) mailClient := internalmail.NewClient(client)
draft := mail.Draft{ draft := internalmail.Draft{
To: recipients, To: recipients,
CC: ccRecipients, CC: ccRecipients,
BCC: bccRecipients, BCC: bccRecipients,
@@ -83,7 +81,7 @@ func draftSaveCmd() *cobra.Command {
Body: msgBody, Body: msgBody,
} }
messageID, err := mailClient.SaveDraft(draft, session.AccessToken) messageID, err := mailClient.SaveDraft(draft, session.MailPassphrase)
if err != nil { if err != nil {
return fmt.Errorf("failed to save draft: %w", err) return fmt.Errorf("failed to save draft: %w", err)
} }
@@ -127,17 +125,16 @@ func draftListCmd() *cobra.Command {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
sessionMgr := auth.NewSessionManager() session, sessionMgr, err := checkAuthenticatedWithManager()
session, err := sessionMgr.GetSession()
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 := mail.NewClient(client) mailClient := internalmail.NewClient(client)
result, err := mailClient.ListDrafts(pageVal, pageSizeVal, session.AccessToken) result, err := mailClient.ListDrafts(pageVal, pageSizeVal, session.MailPassphrase)
if err != nil { if err != nil {
return fmt.Errorf("failed to list drafts: %w", err) return fmt.Errorf("failed to list drafts: %w", err)
} }
@@ -153,7 +150,7 @@ func draftListCmd() *cobra.Command {
} }
func draftEditCmd() *cobra.Command { func draftEditCmd() *cobra.Command {
var to, cc, subject, bodyFile, body string var to, cc, bcc, subject, bodyFile, body string
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "edit <draft-id>", Use: "edit <draft-id>",
@@ -163,16 +160,21 @@ func draftEditCmd() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
messageID := args[0] messageID := args[0]
var recipients []mail.Recipient var recipients []internalmail.Recipient
if to != "" { if to != "" {
recipients = parseRecipients(to) recipients = parseRecipients(to)
} }
var ccRecipients []mail.Recipient var ccRecipients []internalmail.Recipient
if cc != "" { if cc != "" {
ccRecipients = parseRecipients(cc) ccRecipients = parseRecipients(cc)
} }
var bccRecipients []internalmail.Recipient
if bcc != "" {
bccRecipients = parseRecipients(bcc)
}
msgBody := body msgBody := body
if bodyFile != "" { if bodyFile != "" {
data, err := os.ReadFile(bodyFile) data, err := os.ReadFile(bodyFile)
@@ -188,24 +190,24 @@ func draftEditCmd() *cobra.Command {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
sessionMgr := auth.NewSessionManager() session, sessionMgr, err := checkAuthenticatedWithManager()
session, err := sessionMgr.GetSession()
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 := mail.NewClient(client) mailClient := internalmail.NewClient(client)
draft := mail.Draft{ draft := internalmail.Draft{
To: recipients, To: recipients,
CC: ccRecipients, CC: ccRecipients,
BCC: bccRecipients,
Subject: subject, Subject: subject,
Body: msgBody, Body: msgBody,
} }
if err := mailClient.UpdateDraft(messageID, draft, session.AccessToken); err != nil { if err := mailClient.UpdateDraft(messageID, draft, session.MailPassphrase); err != nil {
return fmt.Errorf("failed to update draft: %w", err) return fmt.Errorf("failed to update draft: %w", err)
} }
@@ -216,6 +218,7 @@ func draftEditCmd() *cobra.Command {
cmd.Flags().StringVarP(&to, "to", "t", "", "New recipient addresses (comma-separated)") cmd.Flags().StringVarP(&to, "to", "t", "", "New recipient addresses (comma-separated)")
cmd.Flags().StringVarP(&cc, "cc", "c", "", "New CC addresses (comma-separated)") cmd.Flags().StringVarP(&cc, "cc", "c", "", "New CC addresses (comma-separated)")
cmd.Flags().StringVarP(&bcc, "bcc", "b", "", "New BCC addresses (comma-separated)")
cmd.Flags().StringVarP(&subject, "subject", "s", "", "New draft subject") cmd.Flags().StringVarP(&subject, "subject", "s", "", "New draft subject")
cmd.Flags().StringVarP(&bodyFile, "body-file", "f", "", "File containing new draft body") cmd.Flags().StringVarP(&bodyFile, "body-file", "f", "", "File containing new draft body")
cmd.Flags().StringVar(&body, "body", "", "New inline draft body") cmd.Flags().StringVar(&body, "body", "", "New inline draft body")
@@ -238,17 +241,16 @@ func draftSendCmd() *cobra.Command {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
sessionMgr := auth.NewSessionManager() session, sessionMgr, err := checkAuthenticatedWithManager()
session, err := sessionMgr.GetSession()
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 := mail.NewClient(client) mailClient := internalmail.NewClient(client)
if err := mailClient.SendDraft(messageID); err != nil { if err := mailClient.SendDraft(messageID, session.MailPassphrase); err != nil {
return fmt.Errorf("failed to send draft: %w", err) return fmt.Errorf("failed to send draft: %w", err)
} }

606
cmd/draft_autosave.go Normal file
View File

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

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
View 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
View File

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

411
cmd/folders.go Normal file
View File

@@ -0,0 +1,411 @@
package cmd
import (
"fmt"
"os"
"text/tabwriter"
"github.com/frenocorp/pop/internal/api"
"github.com/frenocorp/pop/internal/auth"
"github.com/frenocorp/pop/internal/config"
"github.com/frenocorp/pop/internal/labels"
"github.com/spf13/cobra"
)
func folderCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "folder",
Short: "Manage folders",
Long: `List, create, update, and delete folders in ProtonMail.`,
}
cmd.AddCommand(folderListCmd())
cmd.AddCommand(folderCreateCmd())
cmd.AddCommand(folderUpdateCmd())
cmd.AddCommand(folderDeleteCmd())
return cmd
}
func labelCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "label",
Short: "Manage labels",
Long: `List, create, update, delete labels and apply/remove them from messages.`,
}
cmd.AddCommand(labelListCmd())
cmd.AddCommand(labelCreateCmd())
cmd.AddCommand(labelUpdateCmd())
cmd.AddCommand(labelDeleteCmd())
cmd.AddCommand(labelApplyCmd())
cmd.AddCommand(labelRemoveCmd())
return cmd
}
// --- Folder Commands ---
func folderListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List folders",
Long: `List all folders in ProtonMail.`,
RunE: func(cmd *cobra.Command, args []string) error {
client, err := newLabelClient()
if err != nil {
return err
}
result, err := client.ListFolders()
if err != nil {
return fmt.Errorf("failed to list folders: %w", err)
}
return printFolders(result.Folders)
},
}
}
func folderCreateCmd() *cobra.Command {
var name, parentID string
cmd := &cobra.Command{
Use: "create <name>",
Short: "Create a folder",
Long: `Create a new folder in ProtonMail.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
folderName := name
if len(args) > 0 && args[0] != "" {
folderName = args[0]
}
if folderName == "" {
return fmt.Errorf("folder name is required")
}
client, err := newLabelClient()
if err != nil {
return err
}
req := labels.CreateFolderRequest{
Name: folderName,
ParentID: parentID,
}
folder, err := client.CreateFolder(req)
if err != nil {
return fmt.Errorf("failed to create folder: %w", err)
}
fmt.Printf("Created folder: %s (ID: %s)\n", folder.Name, folder.ID)
return nil
},
}
cmd.Flags().StringVar(&name, "name", "", "Folder name (or pass as positional argument)")
cmd.Flags().StringVar(&parentID, "parent", "", "Parent folder ID for nested folders")
return cmd
}
func folderUpdateCmd() *cobra.Command {
var newName string
cmd := &cobra.Command{
Use: "update <folder-id>",
Short: "Update a folder",
Long: `Update a folder's name.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
folderID := args[0]
if newName == "" {
return fmt.Errorf("new name is required (--name)")
}
client, err := newLabelClient()
if err != nil {
return err
}
req := labels.UpdateFolderRequest{
Name: newName,
}
folder, err := client.UpdateFolder(folderID, req)
if err != nil {
return fmt.Errorf("failed to update folder: %w", err)
}
fmt.Printf("Updated folder: %s (ID: %s)\n", folder.Name, folder.ID)
return nil
},
}
cmd.Flags().StringVar(&newName, "name", "", "New folder name")
_ = cmd.MarkFlagRequired("name")
return cmd
}
func folderDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete <folder-id>",
Short: "Delete a folder",
Long: `Delete a folder from ProtonMail. This action cannot be undone.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
folderID := args[0]
client, err := newLabelClient()
if err != nil {
return err
}
if err := client.DeleteFolder(folderID); err != nil {
return fmt.Errorf("failed to delete folder: %w", err)
}
fmt.Printf("Deleted folder: %s\n", folderID)
return nil
},
}
}
// --- Label Commands ---
func labelListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List labels",
Long: `List all labels in ProtonMail.`,
RunE: func(cmd *cobra.Command, args []string) error {
client, err := newLabelClient()
if err != nil {
return err
}
result, err := client.ListLabels()
if err != nil {
return fmt.Errorf("failed to list labels: %w", err)
}
return printLabels(result.Labels)
},
}
}
func labelCreateCmd() *cobra.Command {
var name, color string
cmd := &cobra.Command{
Use: "create <name>",
Short: "Create a label",
Long: `Create a new label in ProtonMail.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
labelName := name
if len(args) > 0 && args[0] != "" {
labelName = args[0]
}
if labelName == "" {
return fmt.Errorf("label name is required")
}
client, err := newLabelClient()
if err != nil {
return err
}
req := labels.CreateLabelRequest{
Name: labelName,
Color: color,
}
label, err := client.CreateLabel(req)
if err != nil {
return fmt.Errorf("failed to create label: %w", err)
}
fmt.Printf("Created label: %s (ID: %s)\n", label.Name, label.ID)
return nil
},
}
cmd.Flags().StringVar(&name, "name", "", "Label name (or pass as positional argument)")
cmd.Flags().StringVar(&color, "color", "", "Label color (hex, e.g. #FF0000)")
return cmd
}
func labelUpdateCmd() *cobra.Command {
var newName, newColor string
cmd := &cobra.Command{
Use: "update <label-id>",
Short: "Update a label",
Long: `Update a label's name or color.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
labelID := args[0]
client, err := newLabelClient()
if err != nil {
return err
}
req := labels.UpdateLabelRequest{
Name: newName,
}
if newColor != "" {
req.Color = &newColor
}
label, err := client.UpdateLabel(labelID, req)
if err != nil {
return fmt.Errorf("failed to update label: %w", err)
}
fmt.Printf("Updated label: %s (ID: %s)\n", label.Name, label.ID)
return nil
},
}
cmd.Flags().StringVar(&newName, "name", "", "New label name")
cmd.Flags().StringVar(&newColor, "color", "", "New label color (hex, e.g. #FF0000)")
return cmd
}
func labelDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete <label-id>",
Short: "Delete a label",
Long: `Delete a label from ProtonMail.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
labelID := args[0]
client, err := newLabelClient()
if err != nil {
return err
}
if err := client.DeleteLabel(labelID); err != nil {
return fmt.Errorf("failed to delete label: %w", err)
}
fmt.Printf("Deleted label: %s\n", labelID)
return nil
},
}
}
func labelApplyCmd() *cobra.Command {
return &cobra.Command{
Use: "apply <message-id> <label-id>",
Short: "Apply a label to a message",
Long: `Apply a label to a message in ProtonMail.`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
messageID := args[0]
labelID := args[1]
client, err := newLabelClient()
if err != nil {
return err
}
if err := client.ApplyLabel(messageID, labelID); err != nil {
return fmt.Errorf("failed to apply label: %w", err)
}
fmt.Printf("Applied label %s to message %s\n", labelID, messageID)
return nil
},
}
}
func labelRemoveCmd() *cobra.Command {
return &cobra.Command{
Use: "remove <message-id> <label-id>",
Short: "Remove a label from a message",
Long: `Remove a label from a message in ProtonMail.`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
messageID := args[0]
labelID := args[1]
client, err := newLabelClient()
if err != nil {
return err
}
if err := client.RemoveLabel(messageID, labelID); err != nil {
return fmt.Errorf("failed to remove label: %w", err)
}
fmt.Printf("Removed label %s from message %s\n", labelID, messageID)
return nil
},
}
}
// --- Helpers ---
func newLabelClient() (*labels.Client, error) {
cfgMgr := config.NewConfigManager()
cfg, err := cfgMgr.Load()
if err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
}
sessionMgr, err := auth.NewSessionManager()
if err != nil {
return nil, fmt.Errorf("failed to create session manager: %w", err)
}
session, err := sessionMgr.GetSession()
if err != nil {
return nil, fmt.Errorf("not authenticated: %w", err)
}
apiClient := api.NewProtonMailClient(cfg, sessionMgr)
apiClient.SetAuthHeader(session.AccessToken)
return labels.NewClient(apiClient), nil
}
func printFolders(folders []labels.Folder) error {
if len(folders) == 0 {
fmt.Println("No folders found")
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tName\tType\tMessages")
fmt.Fprintln(w, "--\t----\t----\t--------")
for _, f := range folders {
fmt.Fprintf(w, "%s\t%s\t%d\t%d\n", f.ID, f.Name, f.Type, f.MessageCount)
}
return w.Flush()
}
func printLabels(labelsList []labels.Label) error {
if len(labelsList) == 0 {
fmt.Println("No labels found")
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tName\tColor")
fmt.Fprintln(w, "--\t----\t-----")
for _, l := range labelsList {
fmt.Fprintf(w, "%s\t%s\t%s\n", l.ID, l.Name, l.Color)
}
return w.Flush()
}

View File

@@ -2,6 +2,7 @@ package cmd
import ( import (
"fmt" "fmt"
"net/mail"
"os" "os"
"strconv" "strconv"
"strings" "strings"
@@ -10,10 +11,26 @@ import (
"github.com/frenocorp/pop/internal/api" "github.com/frenocorp/pop/internal/api"
"github.com/frenocorp/pop/internal/auth" "github.com/frenocorp/pop/internal/auth"
"github.com/frenocorp/pop/internal/config" "github.com/frenocorp/pop/internal/config"
"github.com/frenocorp/pop/internal/mail" internalmail "github.com/frenocorp/pop/internal/mail"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
func checkAuthenticatedWithManager() (*auth.Session, *auth.SessionManager, error) {
sessionMgr, err := auth.NewSessionManager()
if err != nil {
return nil, nil, fmt.Errorf("failed to create session manager: %w", err)
}
authenticated, err := sessionMgr.IsAuthenticated()
if err != nil || !authenticated {
return nil, nil, fmt.Errorf("not authenticated (run 'pop login' first): %w", err)
}
session, err := sessionMgr.GetSession()
if err != nil {
return nil, nil, fmt.Errorf("not authenticated: %w", err)
}
return session, sessionMgr, nil
}
func mailCmd() *cobra.Command { func mailCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "mail", Use: "mail",
@@ -27,6 +44,7 @@ func mailCmd() *cobra.Command {
cmd.AddCommand(mailDeleteCmd()) cmd.AddCommand(mailDeleteCmd())
cmd.AddCommand(mailTrashCmd()) cmd.AddCommand(mailTrashCmd())
cmd.AddCommand(mailDraftCmd()) cmd.AddCommand(mailDraftCmd())
cmd.AddCommand(mailSearchCmd())
return cmd return cmd
} }
@@ -46,28 +64,27 @@ func mailListCmd() *cobra.Command {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
sessionMgr := auth.NewSessionManager() session, sessionMgr, err := checkAuthenticatedWithManager()
session, err := sessionMgr.GetSession()
if err != nil { if err != nil {
return fmt.Errorf("not authenticated: %w", err) return err
} }
client := api.NewProtonMailClient(cfg) client := api.NewProtonMailClient(cfg, sessionMgr)
client.SetAuthHeader(session.AccessToken) client.SetAuthHeader(session.AccessToken)
mailClient := mail.NewClient(client) mailClient := internalmail.NewClient(client)
folderVal := mail.FolderInbox folderVal := internalmail.FolderInbox
switch folder { switch folder {
case "inbox": case "inbox":
folderVal = mail.FolderInbox folderVal = internalmail.FolderInbox
case "sent": case "sent":
folderVal = mail.FolderSent folderVal = internalmail.FolderSent
case "drafts": case "drafts":
folderVal = mail.FolderDraft folderVal = internalmail.FolderDraft
case "trash": case "trash":
folderVal = mail.FolderTrash folderVal = internalmail.FolderTrash
case "spam": case "spam":
folderVal = mail.FolderSpam folderVal = internalmail.FolderSpam
default: default:
return fmt.Errorf("unknown folder: %s (valid: inbox, sent, drafts, trash, spam)", folder) return fmt.Errorf("unknown folder: %s (valid: inbox, sent, drafts, trash, spam)", folder)
} }
@@ -97,11 +114,11 @@ func mailListCmd() *cobra.Command {
readPtr = &v readPtr = &v
} }
req := mail.ListMessagesRequest{ req := internalmail.ListMessagesRequest{
Folder: folderVal, Folder: folderVal,
Page: pageVal, Page: pageVal,
PageSize: pageSizeVal, PageSize: pageSizeVal,
Passphrase: session.AccessToken, Passphrase: session.MailPassphrase,
Starred: starredPtr, Starred: starredPtr,
Read: readPtr, Read: readPtr,
Since: since, Since: since,
@@ -141,17 +158,16 @@ func mailReadCmd() *cobra.Command {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
sessionMgr := auth.NewSessionManager() session, sessionMgr, err := checkAuthenticatedWithManager()
session, err := sessionMgr.GetSession()
if err != nil { if err != nil {
return fmt.Errorf("not authenticated: %w", err) return err
} }
client := api.NewProtonMailClient(cfg) client := api.NewProtonMailClient(cfg, sessionMgr)
client.SetAuthHeader(session.AccessToken) client.SetAuthHeader(session.AccessToken)
mailClient := mail.NewClient(client) mailClient := internalmail.NewClient(client)
msg, err := mailClient.GetMessage(messageID, session.AccessToken) msg, err := mailClient.GetMessage(messageID, session.MailPassphrase)
if err != nil { if err != nil {
return fmt.Errorf("failed to get message: %w", err) return fmt.Errorf("failed to get message: %w", err)
} }
@@ -164,7 +180,7 @@ func mailReadCmd() *cobra.Command {
} }
func mailSendCmd() *cobra.Command { func mailSendCmd() *cobra.Command {
var to, cc, bcc, subject, bodyFile string var to, cc, bcc, subject, body, bodyFile string
var html bool var html bool
cmd := &cobra.Command{ cmd := &cobra.Command{
@@ -179,17 +195,19 @@ func mailSendCmd() *cobra.Command {
return fmt.Errorf("subject is required (--subject)") return fmt.Errorf("subject is required (--subject)")
} }
body := "" var bodyContent string
if bodyFile != "" { if body != "" {
bodyContent = body
} else if bodyFile != "" {
data, err := os.ReadFile(bodyFile) data, err := os.ReadFile(bodyFile)
if err != nil { if err != nil {
return fmt.Errorf("failed to read body file: %w", err) return fmt.Errorf("failed to read body file: %w", err)
} }
body = string(data) bodyContent = string(data)
} }
recipients := parseRecipients(to) recipients := parseRecipients(to)
var ccRecipients, bccRecipients []mail.Recipient var ccRecipients, bccRecipients []internalmail.Recipient
if cc != "" { if cc != "" {
ccRecipients = parseRecipients(cc) ccRecipients = parseRecipients(cc)
} }
@@ -203,24 +221,23 @@ func mailSendCmd() *cobra.Command {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
sessionMgr := auth.NewSessionManager() session, sessionMgr, err := checkAuthenticatedWithManager()
session, err := sessionMgr.GetSession()
if err != nil { if err != nil {
return fmt.Errorf("not authenticated: %w", err) return err
} }
client := api.NewProtonMailClient(cfg) client := api.NewProtonMailClient(cfg, sessionMgr)
client.SetAuthHeader(session.AccessToken) client.SetAuthHeader(session.AccessToken)
mailClient := mail.NewClient(client) mailClient := internalmail.NewClient(client)
req := mail.SendRequest{ req := internalmail.SendRequest{
To: recipients, To: recipients,
CC: ccRecipients, CC: ccRecipients,
BCC: bccRecipients, BCC: bccRecipients,
Subject: subject, Subject: subject,
Body: body, Body: bodyContent,
HTML: html, HTML: html,
Passphrase: session.AccessToken, Passphrase: session.MailPassphrase,
} }
if err := mailClient.Send(req); err != nil { if err := mailClient.Send(req); err != nil {
@@ -238,7 +255,7 @@ func mailSendCmd() *cobra.Command {
cmd.Flags().StringVarP(&subject, "subject", "s", "", "Message subject") cmd.Flags().StringVarP(&subject, "subject", "s", "", "Message subject")
cmd.Flags().StringVarP(&bodyFile, "body-file", "f", "", "File containing message body") cmd.Flags().StringVarP(&bodyFile, "body-file", "f", "", "File containing message body")
cmd.Flags().BoolVar(&html, "html", false, "Send body as HTML") cmd.Flags().BoolVar(&html, "html", false, "Send body as HTML")
cmd.Flags().StringVar(&bodyFile, "body", "", "Inline message body") cmd.Flags().StringVar(&body, "body", "", "Inline message body")
_ = cmd.MarkFlagRequired("to") _ = cmd.MarkFlagRequired("to")
return cmd return cmd
@@ -259,15 +276,14 @@ func mailDeleteCmd() *cobra.Command {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
sessionMgr := auth.NewSessionManager() session, sessionMgr, err := checkAuthenticatedWithManager()
session, err := sessionMgr.GetSession()
if err != nil { if err != nil {
return fmt.Errorf("not authenticated: %w", err) return err
} }
client := api.NewProtonMailClient(cfg) client := api.NewProtonMailClient(cfg, sessionMgr)
client.SetAuthHeader(session.AccessToken) client.SetAuthHeader(session.AccessToken)
mailClient := mail.NewClient(client) mailClient := internalmail.NewClient(client)
if err := mailClient.PermanentlyDelete(messageID); err != nil { if err := mailClient.PermanentlyDelete(messageID); err != nil {
return fmt.Errorf("failed to delete message: %w", err) return fmt.Errorf("failed to delete message: %w", err)
@@ -296,17 +312,16 @@ func mailTrashCmd() *cobra.Command {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
sessionMgr := auth.NewSessionManager() session, sessionMgr, err := checkAuthenticatedWithManager()
session, err := sessionMgr.GetSession()
if err != nil { if err != nil {
return fmt.Errorf("not authenticated: %w", err) return err
} }
client := api.NewProtonMailClient(cfg) client := api.NewProtonMailClient(cfg, sessionMgr)
client.SetAuthHeader(session.AccessToken) client.SetAuthHeader(session.AccessToken)
mailClient := mail.NewClient(client) mailClient := internalmail.NewClient(client)
if err := mailClient.MoveToTrash(messageID); err != nil { if err := mailClient.MoveToTrash(messageID, session.MailPassphrase); err != nil {
return fmt.Errorf("failed to move to trash: %w", err) return fmt.Errorf("failed to move to trash: %w", err)
} }
@@ -318,7 +333,7 @@ func mailTrashCmd() *cobra.Command {
return cmd return cmd
} }
func printMessages(messages []mail.Message) error { func printMessages(messages []internalmail.Message) error {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tFrom\tSubject\tDate\tStarred\tRead") fmt.Fprintln(w, "ID\tFrom\tSubject\tDate\tStarred\tRead")
fmt.Fprintln(w, "--\t----\t-------\t----\t-------\t----") fmt.Fprintln(w, "--\t----\t-------\t----\t-------\t----")
@@ -351,7 +366,7 @@ func printMessages(messages []mail.Message) error {
return w.Flush() return w.Flush()
} }
func printMessageDetail(msg *mail.Message) error { func printMessageDetail(msg *internalmail.Message) error {
fmt.Printf("From: %s\n", msg.Sender.DisplayName()) fmt.Printf("From: %s\n", msg.Sender.DisplayName())
fmt.Printf("To: %s\n", formatRecipients(msg.Recipients)) fmt.Printf("To: %s\n", formatRecipients(msg.Recipients))
fmt.Printf("Subject: %s\n", msg.Subject) fmt.Printf("Subject: %s\n", msg.Subject)
@@ -376,26 +391,30 @@ func printMessageDetail(msg *mail.Message) error {
return nil return nil
} }
func parseRecipients(input string) []mail.Recipient { func parseRecipients(input string) []internalmail.Recipient {
var recipients []mail.Recipient var recipients []internalmail.Recipient
for _, addr := range strings.Split(input, ",") { for _, addr := range strings.Split(input, ",") {
addr = strings.TrimSpace(addr) addr = strings.TrimSpace(addr)
if addr == "" { if addr == "" {
continue continue
} }
r := mail.Recipient{Address: addr} parsed, err := mail.ParseAddress(addr)
if strings.Contains(addr, "<") { if err != nil {
parts := strings.SplitN(addr, "<", 2) fmt.Fprintf(os.Stderr, "Warning: invalid address %q: %v\n", addr, err)
r.Name = strings.TrimSpace(parts[0]) continue
r.Address = strings.Trim(parts[1], "<>") }
r := internalmail.Recipient{
Name: parsed.Name,
Address: parsed.Address,
} }
recipients = append(recipients, r) recipients = append(recipients, r)
} }
return recipients return recipients
} }
func formatRecipients(recipients []mail.Recipient) string { func formatRecipients(recipients []internalmail.Recipient) string {
parts := make([]string, len(recipients)) parts := make([]string, len(recipients))
for i, r := range recipients { for i, r := range recipients {
parts[i] = r.DisplayName() parts[i] = r.DisplayName()
@@ -413,3 +432,76 @@ func formatSize(bytes int) string {
return fmt.Sprintf("%d B", bytes) return fmt.Sprintf("%d B", bytes)
} }
} }
func mailSearchCmd() *cobra.Command {
var query, page, pageSize string
cmd := &cobra.Command{
Use: "search <query>",
Short: "Search messages",
Long: `Full-text search across messages in ProtonMail.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
searchQuery := query
if len(args) > 0 && args[0] != "" {
searchQuery = args[0]
}
if searchQuery == "" {
return fmt.Errorf("search query is required")
}
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)
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
}
req := internalmail.SearchRequest{
Query: searchQuery,
Page: pageVal,
PageSize: pageSizeVal,
Passphrase: session.MailPassphrase,
}
result, err := mailClient.SearchMessages(req)
if err != nil {
return fmt.Errorf("failed to search messages: %w", err)
}
fmt.Printf("Found %d message(s) for query: %q\n", result.Total, searchQuery)
if len(result.Messages) == 0 {
return nil
}
return printMessages(result.Messages)
},
}
cmd.Flags().StringVar(&query, "query", "", "Search query (or pass as positional argument)")
cmd.Flags().StringVar(&page, "page", "1", "Page number")
cmd.Flags().StringVar(&pageSize, "page-size", "20", "Results per page (max 100)")
return cmd
}

299
cmd/pgp.go Normal file
View File

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

111
cmd/plugin.go Normal file
View File

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

View File

@@ -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())
@@ -24,7 +52,18 @@ func NewRootCmd() *cobra.Command {
rootCmd.AddCommand(mailDraftCmd()) rootCmd.AddCommand(mailDraftCmd())
rootCmd.AddCommand(contactCmd()) rootCmd.AddCommand(contactCmd())
rootCmd.AddCommand(attachmentCmd()) rootCmd.AddCommand(attachmentCmd())
rootCmd.AddCommand(folderCmd())
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
View 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
View File

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

162
cmd/webhook.go Normal file
View File

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

10
go.mod
View File

@@ -8,13 +8,23 @@ require (
) )
require ( require (
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
github.com/99designs/keyring v1.2.2 // indirect
github.com/ProtonMail/go-crypto v1.4.1 // indirect github.com/ProtonMail/go-crypto v1.4.1 // indirect
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
github.com/cloudflare/circl v1.6.2 // indirect github.com/cloudflare/circl v1.6.2 // indirect
github.com/danieljoos/wincred v1.1.2 // indirect
github.com/dvsekhvalnov/jose2go v1.5.0 // indirect
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/manifoldco/promptui v0.9.0 // indirect
github.com/mtibben/percent v0.2.1 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/crypto v0.41.0 // indirect golang.org/x/crypto v0.41.0 // indirect
golang.org/x/sys v0.35.0 // indirect golang.org/x/sys v0.35.0 // indirect
golang.org/x/term v0.34.0 // indirect
golang.org/x/text v0.28.0 // indirect golang.org/x/text v0.28.0 // indirect
) )

31
go.sum
View File

@@ -1,16 +1,40 @@
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs=
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4=
github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0=
github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk=
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k= github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k=
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw= github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw=
github.com/ProtonMail/gopenpgp/v2 v2.10.0 h1:llCzLvntC9+iH+if/na4AgKTef/Zm4vpaRrR3+JdKvo= github.com/ProtonMail/gopenpgp/v2 v2.10.0 h1:llCzLvntC9+iH+if/na4AgKTef/Zm4vpaRrR3+JdKvo=
github.com/ProtonMail/gopenpgp/v2 v2.10.0/go.mod h1:dc0h9Pg3ftfN0U4pfRzujilfh61A2R52wgMkZWcWm2I= github.com/ProtonMail/gopenpgp/v2 v2.10.0/go.mod h1:dc0h9Pg3ftfN0U4pfRzujilfh61A2R52wgMkZWcWm2I=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ= github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0=
github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQxaLAeM=
github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU=
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0=
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU=
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs=
github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -20,6 +44,7 @@ github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -36,9 +61,11 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -47,6 +74,8 @@ golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -60,5 +89,7 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,235 @@
package accounts
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/frenocorp/pop/internal/config"
)
// Account represents a named ProtonMail account profile.
type Account struct {
Name string `json:"name"`
Email string `json:"email"`
UID string `json:"uid,omitempty"`
APIBaseURL string `json:"api_base_url"`
Default bool `json:"default"`
CreatedAt string `json:"created_at"`
LastUsedAt string `json:"last_used_at,omitempty"`
}
// AccountsStore manages multiple named account profiles.
type AccountsStore struct {
configDir string
accountsFile string
}
// NewAccountsStore creates a new store for managing multiple accounts.
func NewAccountsStore() (*AccountsStore, error) {
cfg := config.NewConfigManager()
configDir := cfg.ConfigDir()
return &AccountsStore{
configDir: configDir,
accountsFile: filepath.Join(configDir, "accounts.json"),
}, nil
}
// LoadAccounts reads all stored accounts from disk.
func (s *AccountsStore) LoadAccounts() ([]Account, error) {
if err := os.MkdirAll(s.configDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create config dir: %w", err)
}
data, err := os.ReadFile(s.accountsFile)
if err != nil {
if os.IsNotExist(err) {
return []Account{}, nil
}
return nil, fmt.Errorf("failed to read accounts file: %w", err)
}
var accounts []Account
if err := json.Unmarshal(data, &accounts); err != nil {
return nil, fmt.Errorf("failed to parse accounts: %w", err)
}
return accounts, nil
}
// SaveAccounts writes all accounts to disk.
func (s *AccountsStore) SaveAccounts(accounts []Account) error {
if err := os.MkdirAll(s.configDir, 0700); err != nil {
return fmt.Errorf("failed to create config dir: %w", err)
}
data, err := json.MarshalIndent(accounts, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal accounts: %w", err)
}
if err := os.WriteFile(s.accountsFile, data, 0600); err != nil {
return fmt.Errorf("failed to write accounts file: %w", err)
}
return nil
}
// AddAccount adds a new account profile. If name conflicts, returns an error.
func (s *AccountsStore) AddAccount(name, email, apiBaseURL string, isDefault bool) error {
accounts, err := s.LoadAccounts()
if err != nil {
return err
}
for _, acc := range accounts {
if acc.Name == name {
return fmt.Errorf("account with name %q already exists", name)
}
}
if apiBaseURL == "" {
apiBaseURL = "https://api.protonmail.ch"
}
account := Account{
Name: name,
Email: email,
APIBaseURL: apiBaseURL,
Default: isDefault,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
if isDefault {
for i := range accounts {
accounts[i].Default = false
}
}
accounts = append(accounts, account)
return s.SaveAccounts(accounts)
}
// GetAccount retrieves an account by name. Falls back to default if name is empty.
func (s *AccountsStore) GetAccount(name string) (*Account, error) {
accounts, err := s.LoadAccounts()
if err != nil {
return nil, err
}
if name != "" {
for _, acc := range accounts {
if acc.Name == name {
return &acc, nil
}
}
return nil, fmt.Errorf("account %q not found", name)
}
for _, acc := range accounts {
if acc.Default {
return &acc, nil
}
}
return nil, fmt.Errorf("no default account set (use 'pop accounts add' to create one)")
}
// UpdateAccount updates an existing account's fields.
func (s *AccountsStore) UpdateAccount(name string, email, apiBaseURL *string, isDefault *bool) (*Account, error) {
accounts, err := s.LoadAccounts()
if err != nil {
return nil, err
}
found := false
for i, acc := range accounts {
if acc.Name == name {
if email != nil {
accounts[i].Email = *email
}
if apiBaseURL != nil {
accounts[i].APIBaseURL = *apiBaseURL
}
if isDefault != nil && *isDefault {
for j := range accounts {
accounts[j].Default = false
}
accounts[i].Default = true
}
found = true
accounts[i].LastUsedAt = time.Now().UTC().Format(time.RFC3339)
if err := s.SaveAccounts(accounts); err != nil {
return nil, err
}
return &accounts[i], nil
}
}
if !found {
return nil, fmt.Errorf("account %q not found", name)
}
return nil, nil
}
// SetDefaultAccount sets the default account by name.
func (s *AccountsStore) SetDefaultAccount(name string) error {
accounts, err := s.LoadAccounts()
if err != nil {
return err
}
found := false
for i, acc := range accounts {
if acc.Name == name {
accounts[i].Default = true
found = true
} else {
accounts[i].Default = false
}
}
if !found {
return fmt.Errorf("account %q not found", name)
}
return s.SaveAccounts(accounts)
}
// RemoveAccount removes an account by name.
func (s *AccountsStore) RemoveAccount(name string) error {
accounts, err := s.LoadAccounts()
if err != nil {
return err
}
newAccounts := make([]Account, 0, len(accounts))
found := false
for _, acc := range accounts {
if acc.Name == name {
found = true
continue
}
newAccounts = append(newAccounts, acc)
}
if !found {
return fmt.Errorf("account %q not found", name)
}
return s.SaveAccounts(newAccounts)
}
// AccountSessionDir returns the session directory for a given account name.
func AccountSessionDir(configDir, accountName string) string {
return filepath.Join(configDir, "accounts", accountName)
}
// AccountSessionFile returns the session file path for a given account.
func AccountSessionFile(configDir, accountName string) string {
return filepath.Join(AccountSessionDir(configDir, accountName), "session.json")
}

View File

@@ -2,16 +2,166 @@ 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
@@ -19,8 +169,17 @@ type ProtonMailClient struct {
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
@@ -28,19 +187,27 @@ type RateLimiter struct {
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)
resp, err := c.httpClient.Do(req)
if err != nil { 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)
if err != nil {
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
// Check if it's a network-level error
if netErr := new(net.OpError); errors.As(err, &netErr) {
return nil, NewNetError(netErr, "network error while communicating with API")
}
// Check for dial/connection errors
return nil, err return nil, err
} }
// Record the request if resp.StatusCode == 0 {
c.rateLimiter.mu.Lock() c.onConnDown()
c.rateLimiter.requests = append(c.rateLimiter.requests, time.Now()) return nil, NewNetError(errors.New("no response received"), "received no response from API")
c.rateLimiter.mu.Unlock()
// Check for API errors
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
var apiErr APIError
if err := json.Unmarshal(body, &apiErr); err == nil {
resp.Body = io.NopCloser(io.MultiReader(io.NopCloser(bytes.NewReader(body)), bytes.NewReader(body)))
return resp, &apiErr
}
} }
return resp, nil return resp, nil
} }
type APIError struct { // shouldRetryError determines if an error condition warrants a retry.
HTTPStatus int `json:"-"` func (c *ProtonMailClient) shouldRetryError(err error, resp *http.Response) bool {
Code int `json:"Code,omitempty"` if err == nil {
Message string `json:"Message,omitempty"` return false
}
// Context errors are not retryable
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
// Network errors (NetError wraps net.OpError) are retryable
if _, ok := errors.Unwrap(err).(*NetError); ok {
return true
}
if _, ok := err.(*NetError); ok {
return true
}
// Raw net.OpError from http.Client.Do are retryable
if _, ok := err.(*net.OpError); ok {
return true
}
return false
} }
func (e *APIError) Error() string { // shouldRetryResponse determines if a response status warrants a retry.
return fmt.Sprintf("API error %d: %s", e.HTTPStatus, e.Message) func (c *ProtonMailClient) shouldRetryResponse(resp *http.Response) bool {
if resp == nil {
return false
}
return resp.StatusCode == http.StatusTooManyRequests ||
resp.StatusCode == http.StatusServiceUnavailable
}
// calculateBackoff computes the retry delay using exponential backoff with jitter.
// If the response contains a Retry-After header, that value is used as the base.
func (c *ProtonMailClient) calculateBackoff(attempt int, resp *http.Response) time.Duration {
var delay time.Duration
// Check for Retry-After header first
if resp != nil {
retryAfter := c.parseRetryAfter(resp)
if retryAfter > 0 {
delay = retryAfter
}
}
// Fall back to exponential backoff
if delay == 0 {
base := c.retryConfig.BaseBackoff
delay = base * (1 << uint(attempt)) // Exponential: 0.5s, 1s, 2s, ...
}
// Cap at max wait time
if delay > c.retryConfig.MaxWaitTime {
delay = c.retryConfig.MaxWaitTime
}
// Add jitter (0-10 seconds) to avoid thundering herd
jitter := time.Duration(c.randIntn(10)) * time.Second
delay += jitter
return delay
}
// randIntn returns a thread-safe random integer in [0, n) using crypto/rand.
func (c *ProtonMailClient) randIntn(n int) int {
b := make([]byte, 4)
_, _ = rand.Read(b)
return int(binary.BigEndian.Uint32(b) % uint32(n))
}
// parseRetryAfter parses the Retry-After header and returns the duration.
// Returns 0 if the header is missing or invalid.
func (c *ProtonMailClient) parseRetryAfter(resp *http.Response) time.Duration {
retryAfterStr := resp.Header.Get("Retry-After")
if retryAfterStr == "" {
return 0
}
// Try parsing as seconds (integer)
seconds, err := strconv.Atoi(retryAfterStr)
if err != nil {
// Try parsing as HTTP date
t, err := time.Parse(time.RFC1123, retryAfterStr)
if err != nil {
return 0
}
delay := t.Sub(time.Now())
if delay < 0 {
delay = 0
}
return delay
}
return time.Duration(seconds) * time.Second
} }

View File

@@ -6,18 +6,60 @@ import (
"path/filepath" "path/filepath"
) )
// ErrInvalidAttachmentID is returned when attachmentID contains unsafe characters
var ErrInvalidAttachmentID = os.ErrInvalid
type AttachmentManager struct { type AttachmentManager struct {
attachmentsDir string attachmentsDir string
} }
const maxUploadSize = 50 * 1024 * 1024 // 50MB
func NewAttachmentManager() *AttachmentManager { func NewAttachmentManager() *AttachmentManager {
return &AttachmentManager{ return &AttachmentManager{
attachmentsDir: filepath.Join(os.Getenv("HOME"), ".config", "pop", "attachments"), attachmentsDir: filepath.Join(os.Getenv("HOME"), ".config", "pop", "attachments"),
} }
} }
// isAttachmentIDSafe validates that attachmentID contains only safe characters
// to prevent path traversal attacks
func isAttachmentIDSafe(id string) bool {
if id == "" {
return false
}
// Only allow alphanumeric, hyphen, and underscore
for _, r := range id {
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '-' || r == '_') {
return false
}
}
return true
}
// sanitizeAttachmentID ensures the attachmentID is safe and the resolved path
// is within the attachments directory
func sanitizeAttachmentID(id string) (string, error) {
if !isAttachmentIDSafe(id) {
return "", ErrInvalidAttachmentID
}
// Use filepath.Clean to resolve any .. or . components
cleanID := filepath.Clean(id)
if cleanID != id {
return "", ErrInvalidAttachmentID
}
return cleanID, nil
}
func (m *AttachmentManager) Download(attachmentID, name, destPath string) error { func (m *AttachmentManager) Download(attachmentID, name, destPath string) error {
if err := os.MkdirAll(m.attachmentsDir, 0755); err != nil { // Sanitize attachmentID to prevent path traversal
if sanitizedID, err := sanitizeAttachmentID(attachmentID); err != nil {
return err
} else {
attachmentID = sanitizedID
}
if err := os.MkdirAll(m.attachmentsDir, 0700); err != nil {
return err return err
} }
@@ -33,30 +75,49 @@ func (m *AttachmentManager) Download(attachmentID, name, destPath string) error
return err return err
} }
return os.WriteFile(dest, data, 0644) return os.WriteFile(dest, data, 0600)
} }
func (m *AttachmentManager) Upload(attachmentID, name string, reader io.Reader) error { func (m *AttachmentManager) Upload(attachmentID, name string, reader io.Reader) error {
if err := os.MkdirAll(m.attachmentsDir, 0755); err != nil { // Sanitize attachmentID to prevent path traversal
if sanitizedID, err := sanitizeAttachmentID(attachmentID); err != nil {
return err
} else {
attachmentID = sanitizedID
}
if err := os.MkdirAll(m.attachmentsDir, 0700); err != nil {
return err return err
} }
path := filepath.Join(m.attachmentsDir, attachmentID) // Limit reader to maxUploadSize to prevent DoS
limitedReader := io.LimitReader(reader, maxUploadSize)
data, err := io.ReadAll(reader) data, err := io.ReadAll(limitedReader)
if err != nil { if err != nil {
return err return err
} }
return os.WriteFile(path, data, 0644) return os.WriteFile(filepath.Join(m.attachmentsDir, attachmentID), data, 0600)
} }
func (m *AttachmentManager) Get(attachmentID string) ([]byte, error) { func (m *AttachmentManager) Get(attachmentID string) ([]byte, error) {
// Sanitize attachmentID to prevent path traversal
if sanitizedID, err := sanitizeAttachmentID(attachmentID); err != nil {
return nil, err
} else {
attachmentID = sanitizedID
}
path := filepath.Join(m.attachmentsDir, attachmentID) path := filepath.Join(m.attachmentsDir, attachmentID)
return os.ReadFile(path) return os.ReadFile(path)
} }
func (m *AttachmentManager) Delete(attachmentID string) error { func (m *AttachmentManager) Delete(attachmentID string) error {
// Sanitize attachmentID to prevent path traversal
if sanitizedID, err := sanitizeAttachmentID(attachmentID); err != nil {
return err
} else {
attachmentID = sanitizedID
}
path := filepath.Join(m.attachmentsDir, attachmentID) path := filepath.Join(m.attachmentsDir, attachmentID)
return os.Remove(path) return os.Remove(path)
} }

View File

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

View File

@@ -1,12 +1,24 @@
package auth package auth
import ( import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time"
"github.com/99designs/keyring"
"github.com/frenocorp/pop/internal/config" "github.com/frenocorp/pop/internal/config"
"github.com/manifoldco/promptui"
) )
type Session struct { type Session struct {
@@ -15,43 +27,290 @@ type Session struct {
RefreshToken string `json:"refresh_token"` RefreshToken string `json:"refresh_token"`
ExpiresAt int64 `json:"expires_at"` ExpiresAt int64 `json:"expires_at"`
TwoFAEnabled bool `json:"two_factor_enabled"` TwoFAEnabled bool `json:"two_factor_enabled"`
MailPassphrase string `json:"mail_passphrase,omitempty"`
} }
type SessionManager struct { type SessionManager struct {
configDir string configDir string
sessionFile string sessionFile string
keyring keyring.Keyring
} }
func NewSessionManager() *SessionManager { func NewSessionManager() (*SessionManager, error) {
cfg := config.NewConfigManager() cfg := config.NewConfigManager()
return &SessionManager{ configDir := cfg.ConfigDir()
configDir: cfg.ConfigDir(),
sessionFile: filepath.Join(cfg.ConfigDir(), "session.json"), k, err := keyring.Open(keyring.Config{
ServiceName: "pop-cli",
FileDir: filepath.Join(configDir, "keyring"),
})
if err != nil {
return nil, fmt.Errorf("failed to open keyring: %w", err)
} }
return &SessionManager{
configDir: configDir,
sessionFile: filepath.Join(configDir, "session.json"),
keyring: k,
}, nil
} }
func (m *SessionManager) Login() error { func (m *SessionManager) LoginWithCredentials(ctx context.Context, apiBaseURL, email, password, mailPassphrase string) error {
// TODO: Implement interactive login with 2FA support if err := os.MkdirAll(m.configDir, 0700); err != nil {
// This will call the ProtonMail API and store the session
session := Session{
UID: "placeholder-uid",
AccessToken: "placeholder-token",
RefreshToken: "placeholder-refresh",
ExpiresAt: 0,
TwoFAEnabled: false,
}
if err := os.MkdirAll(m.configDir, 0755); err != nil {
return fmt.Errorf("failed to create config dir: %w", err) return fmt.Errorf("failed to create config dir: %w", err)
} }
data, err := json.MarshalIndent(session, "", " ") if err := os.MkdirAll(filepath.Join(m.configDir, "keyring"), 0700); err != nil {
if err != nil { return fmt.Errorf("failed to create keyring dir: %w", err)
return fmt.Errorf("failed to marshal session: %w", err)
} }
if err := os.WriteFile(m.sessionFile, data, 0600); err != nil { authURL := fmt.Sprintf("%s/auth", apiBaseURL)
return fmt.Errorf("failed to write session file: %w", err)
payload := map[string]string{
"Email": email,
"Password": password,
}
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal auth payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", authURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create auth request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to connect to ProtonMail API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("authentication failed (status %d): %s", resp.StatusCode, string(body))
}
var authResponse struct {
UID string `json:"UID"`
AccessToken string `json:"AccessToken"`
RefreshToken string `json:"RefreshToken"`
ExpiresIn int `json:"ExpiresIn"`
TwoFARequired bool `json:"TwoFARequired"`
}
if err := json.NewDecoder(resp.Body).Decode(&authResponse); err != nil {
return fmt.Errorf("failed to parse auth response: %w", err)
}
session := Session{
UID: authResponse.UID,
AccessToken: authResponse.AccessToken,
RefreshToken: authResponse.RefreshToken,
ExpiresAt: time.Now().Unix() + int64(authResponse.ExpiresIn),
TwoFAEnabled: authResponse.TwoFARequired,
MailPassphrase: mailPassphrase,
}
encryptedForFile, err := encryptSession(session)
if err != nil {
return fmt.Errorf("failed to encrypt session: %w", err)
}
if err := m.keyring.Set(keyring.Item{
Key: "session",
Data: encryptedForFile,
}); err != nil {
return fmt.Errorf("failed to store session in keyring: %w", err)
}
if err := os.WriteFile(m.sessionFile, encryptedForFile, 0600); err != nil {
return fmt.Errorf("failed to write encrypted session file: %w", err)
}
fmt.Println("Logged in successfully")
return nil
}
func (m *SessionManager) LoginInteractive(ctx context.Context, apiBaseURL string) error {
if err := os.MkdirAll(m.configDir, 0700); err != nil {
return fmt.Errorf("failed to create config dir: %w", err)
}
if err := os.MkdirAll(filepath.Join(m.configDir, "keyring"), 0700); err != nil {
return fmt.Errorf("failed to create keyring dir: %w", err)
}
emailPrompt := promptui.Prompt{
Label: "ProtonMail email",
Validate: func(input string) error {
if !strings.Contains(input, "@") || !strings.Contains(input, ".") {
return fmt.Errorf("invalid email format")
}
parts := strings.Split(input, "@")
if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) < 3 {
return fmt.Errorf("invalid email format")
}
return nil
},
}
email, err := emailPrompt.Run()
if err != nil {
return fmt.Errorf("failed to read email: %w", err)
}
passwordPrompt := promptui.Prompt{
Label: "ProtonMail password",
Mask: '*',
}
password, err := passwordPrompt.Run()
if err != nil {
return fmt.Errorf("failed to read password: %w", err)
}
passphrasePrompt := promptui.Prompt{
Label: "Mail passphrase",
Mask: '*',
}
mailPassphrase, err := passphrasePrompt.Run()
if err != nil {
return fmt.Errorf("failed to read mail passphrase: %w", err)
}
authURL := fmt.Sprintf("%s/auth", apiBaseURL)
payload := map[string]string{
"Email": email,
"Password": password,
}
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal auth payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", authURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create auth request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to connect to ProtonMail API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("authentication failed (status %d): %s", resp.StatusCode, string(body))
}
var authResponse struct {
UID string `json:"UID"`
AccessToken string `json:"AccessToken"`
RefreshToken string `json:"RefreshToken"`
ExpiresIn int `json:"ExpiresIn"`
TwoFARequired bool `json:"TwoFARequired"`
TwoFAChallenge struct {
Type string `json:"Type"`
} `json:"TwoFAChallenge"`
}
if err := json.NewDecoder(resp.Body).Decode(&authResponse); err != nil {
return fmt.Errorf("failed to parse auth response: %w", err)
}
session := Session{
UID: authResponse.UID,
AccessToken: authResponse.AccessToken,
RefreshToken: authResponse.RefreshToken,
ExpiresAt: time.Now().Unix() + int64(authResponse.ExpiresIn),
TwoFAEnabled: authResponse.TwoFARequired,
MailPassphrase: mailPassphrase,
}
if session.TwoFAEnabled {
fmt.Println("\n2FA authentication required")
totpPrompt := promptui.Prompt{
Label: "Enter TOTP code",
Validate: func(input string) error {
if len(input) != 6 {
return fmt.Errorf("TOTP code must be 6 digits")
}
return nil
},
}
totpCode, err := totpPrompt.Run()
if err != nil {
return fmt.Errorf("failed to read TOTP code: %w", err)
}
totpURL := fmt.Sprintf("%s/auth/verify", apiBaseURL)
totpPayload := map[string]string{
"UID": session.UID,
"Code": totpCode,
}
totpJSON, err := json.Marshal(totpPayload)
if err != nil {
return fmt.Errorf("failed to marshal TOTP payload: %w", err)
}
totpReq, err := http.NewRequestWithContext(ctx, "POST", totpURL, bytes.NewBuffer(totpJSON))
if err != nil {
return fmt.Errorf("failed to create TOTP request: %w", err)
}
totpReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", session.AccessToken))
totpReq.Header.Set("Content-Type", "application/json")
totpResp, err := client.Do(totpReq)
if err != nil {
return fmt.Errorf("failed to verify TOTP: %w", err)
}
defer totpResp.Body.Close()
if totpResp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(totpResp.Body)
return fmt.Errorf("TOTP verification failed (status %d): %s", totpResp.StatusCode, string(body))
}
var finalAuth struct {
AccessToken string `json:"AccessToken"`
RefreshToken string `json:"RefreshToken"`
ExpiresIn int `json:"ExpiresIn"`
}
if err := json.NewDecoder(totpResp.Body).Decode(&finalAuth); err != nil {
return fmt.Errorf("failed to parse TOTP response: %w", err)
}
session.AccessToken = finalAuth.AccessToken
session.RefreshToken = finalAuth.RefreshToken
session.ExpiresAt = time.Now().Unix() + int64(finalAuth.ExpiresIn)
}
encryptedForFile, err := encryptSession(session)
if err != nil {
return fmt.Errorf("failed to encrypt session: %w", err)
}
if err := m.keyring.Set(keyring.Item{
Key: "session",
Data: encryptedForFile,
}); err != nil {
return fmt.Errorf("failed to store session in keyring: %w", err)
}
if err := os.WriteFile(m.sessionFile, encryptedForFile, 0600); err != nil {
return fmt.Errorf("failed to write encrypted session file: %w", err)
} }
fmt.Println("Logged in successfully") fmt.Println("Logged in successfully")
@@ -63,28 +322,216 @@ func (m *SessionManager) Logout() error {
return fmt.Errorf("failed to remove session file: %w", err) return fmt.Errorf("failed to remove session file: %w", err)
} }
if err := m.keyring.Remove("session"); err != nil {
return fmt.Errorf("failed to remove keyring entry: %w", err)
}
fmt.Println("Logged out successfully") fmt.Println("Logged out successfully")
return nil return nil
} }
func (m *SessionManager) GetSession() (*Session, error) { func (m *SessionManager) GetSession() (*Session, error) {
// First, try to get from keyring (encrypted storage)
item, err := m.keyring.Get("session")
if err == nil {
session, err := decryptSession(item.Data)
if err != nil {
return nil, fmt.Errorf("failed to decrypt session from keyring: %w", err)
}
return &session, nil
}
// If not in keyring, read from encrypted file
data, err := os.ReadFile(m.sessionFile) data, err := os.ReadFile(m.sessionFile)
if err != nil { if err != nil {
if err == os.ErrNotExist {
return nil, fmt.Errorf("no session found: %w", err)
}
return nil, fmt.Errorf("failed to read session file: %w", err) return nil, fmt.Errorf("failed to read session file: %w", err)
} }
var session Session // Decrypt the session data
if err := json.Unmarshal(data, &session); err != nil { session, err := decryptSession(data)
return nil, fmt.Errorf("failed to parse session: %w", err) if err != nil {
return nil, fmt.Errorf("failed to decrypt session: %w", err)
} }
return &session, nil return &session, nil
} }
func (m *SessionManager) IsAuthenticated() (bool, error) { func (m *SessionManager) IsAuthenticated() (bool, error) {
_, err := m.GetSession() session, err := m.GetSession()
if err != nil { if err != nil {
return false, err return false, err
} }
if time.Now().Unix() > session.ExpiresAt {
return false, fmt.Errorf("session expired at %d", session.ExpiresAt)
}
return true, nil return true, nil
} }
func (m *SessionManager) RefreshToken() error {
return m.RefreshTokenWithContext(context.Background())
}
func (m *SessionManager) RefreshTokenWithContext(ctx context.Context) error {
session, err := m.GetSession()
if err != nil {
return fmt.Errorf("failed to get session: %w", err)
}
if session.RefreshToken == "" {
return fmt.Errorf("no refresh token available")
}
apiBaseURL := "https://api.protonmail.ch"
refreshURL := fmt.Sprintf("%s/auth/refresh", apiBaseURL)
payload := map[string]string{
"UID": session.UID,
"RefreshToken": session.RefreshToken,
}
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal refresh payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", refreshURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create refresh request: %w", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", session.AccessToken))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to connect to ProtonMail API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("token refresh failed (status %d): %s", resp.StatusCode, string(body))
}
var refreshResponse struct {
AccessToken string `json:"AccessToken"`
RefreshToken string `json:"RefreshToken"`
ExpiresIn int `json:"ExpiresIn"`
}
if err := json.NewDecoder(resp.Body).Decode(&refreshResponse); err != nil {
return fmt.Errorf("failed to parse refresh response: %w", err)
}
session.AccessToken = refreshResponse.AccessToken
session.RefreshToken = refreshResponse.RefreshToken
session.ExpiresAt = time.Now().Unix() + int64(refreshResponse.ExpiresIn)
encryptedForFile, err := encryptSession(*session)
if err != nil {
return fmt.Errorf("failed to encrypt updated session: %w", err)
}
if err := m.keyring.Set(keyring.Item{
Key: "session",
Data: encryptedForFile,
}); err != nil {
return fmt.Errorf("failed to update session in keyring: %w", err)
}
_ = os.WriteFile(m.sessionFile, encryptedForFile, 0600)
return nil
}
// encryptSession encrypts the session data using AES-256-GCM
func encryptSession(session Session) ([]byte, error) {
// Generate a random 256-bit key
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil, fmt.Errorf("failed to generate encryption key: %w", err)
}
// Generate a random 12-byte nonce
nonce := make([]byte, 12)
if _, err := rand.Read(nonce); err != nil {
return nil, fmt.Errorf("failed to generate nonce: %w", err)
}
// Create AES-GCM cipher
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM: %w", err)
}
// Encrypt the session data
sessionData, err := json.Marshal(session)
if err != nil {
return nil, fmt.Errorf("failed to marshal session for encryption: %w", err)
}
sealedData := aead.Seal(nil, nonce, sessionData, nil)
// Prepend key and nonce (base64 encoded for readability in file)
header := fmt.Sprintf("%s|%s|", base64.StdEncoding.EncodeToString(key), base64.StdEncoding.EncodeToString(nonce))
return []byte(header + string(sealedData)), nil
}
// decryptSession decrypts the session data
func decryptSession(encryptedData []byte) (Session, error) {
// Split header and encrypted data
parts := strings.Split(string(encryptedData), "|")
if len(parts) != 3 {
return Session{}, fmt.Errorf("invalid encrypted data format")
}
// Decode key and nonce
key, err := base64.StdEncoding.DecodeString(parts[0])
if err != nil {
return Session{}, fmt.Errorf("failed to decode key: %w", err)
}
nonce, err := base64.StdEncoding.DecodeString(parts[1])
if err != nil {
return Session{}, fmt.Errorf("failed to decode nonce: %w", err)
}
// Decrypt
block, err := aes.NewCipher(key)
if err != nil {
return Session{}, fmt.Errorf("failed to create AES cipher: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return Session{}, fmt.Errorf("failed to create GCM: %w", err)
}
sealedData, err := base64.StdEncoding.DecodeString(parts[2])
if err != nil {
return Session{}, fmt.Errorf("failed to decode sealed data: %w", err)
}
data, err := aead.Open(nil, nonce, sealedData, nil)
if err != nil {
return Session{}, fmt.Errorf("failed to decrypt session: %w", err)
}
var session Session
if err := json.Unmarshal(data, &session); err != nil {
return Session{}, fmt.Errorf("failed to unmarshal session: %w", err)
}
return session, nil
}

View File

@@ -61,7 +61,7 @@ func (m *ConfigManager) Load() (*Config, error) {
} }
func (m *ConfigManager) Save(config *Config) error { func (m *ConfigManager) Save(config *Config) error {
if err := os.MkdirAll(m.configDir, 0755); 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)
} }

View File

@@ -5,12 +5,14 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"sync"
"time" "time"
"github.com/frenocorp/pop/internal/config" "github.com/frenocorp/pop/internal/config"
) )
type ContactManager struct { type ContactManager struct {
mu sync.Mutex
configDir string configDir string
contactsFile string contactsFile string
} }
@@ -25,6 +27,8 @@ func NewContactManager() *ContactManager {
} }
func (m *ContactManager) List(req ListContactsRequest) (*ListContactsResponse, error) { func (m *ContactManager) List(req ListContactsRequest) (*ListContactsResponse, error) {
m.mu.Lock()
defer m.mu.Unlock()
contacts, err := m.loadContacts() contacts, err := m.loadContacts()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -60,6 +64,8 @@ func (m *ContactManager) List(req ListContactsRequest) (*ListContactsResponse, e
} }
func (m *ContactManager) Create(req CreateContactRequest) (*Contact, error) { func (m *ContactManager) Create(req CreateContactRequest) (*Contact, error) {
m.mu.Lock()
defer m.mu.Unlock()
contacts, err := m.loadContacts() contacts, err := m.loadContacts()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -88,6 +94,8 @@ func (m *ContactManager) Create(req CreateContactRequest) (*Contact, error) {
} }
func (m *ContactManager) Get(id string) (*Contact, error) { func (m *ContactManager) Get(id string) (*Contact, error) {
m.mu.Lock()
defer m.mu.Unlock()
contacts, err := m.loadContacts() contacts, err := m.loadContacts()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -103,6 +111,8 @@ func (m *ContactManager) Get(id string) (*Contact, error) {
} }
func (m *ContactManager) Update(id string, req UpdateContactRequest) (*Contact, error) { func (m *ContactManager) Update(id string, req UpdateContactRequest) (*Contact, error) {
m.mu.Lock()
defer m.mu.Unlock()
contacts, err := m.loadContacts() contacts, err := m.loadContacts()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -145,6 +155,8 @@ func (m *ContactManager) Update(id string, req UpdateContactRequest) (*Contact,
} }
func (m *ContactManager) Delete(id string) error { func (m *ContactManager) Delete(id string) error {
m.mu.Lock()
defer m.mu.Unlock()
contacts, err := m.loadContacts() contacts, err := m.loadContacts()
if err != nil { if err != nil {
return err return err
@@ -178,7 +190,7 @@ func (m *ContactManager) loadContacts() ([]Contact, error) {
} }
func (m *ContactManager) saveContacts(contacts []Contact) error { func (m *ContactManager) saveContacts(contacts []Contact) error {
if err := os.MkdirAll(m.configDir, 0755); err != nil { if err := os.MkdirAll(m.configDir, 0700); err != nil {
return err return err
} }

367
internal/labels/client.go Normal file
View File

@@ -0,0 +1,367 @@
package labels
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"github.com/frenocorp/pop/internal/api"
)
type Client struct {
apiClient *api.ProtonMailClient
baseURL string
}
func NewClient(apiClient *api.ProtonMailClient) *Client {
return &Client{
apiClient: apiClient,
baseURL: apiClient.GetBaseURL(),
}
}
// --- Folders ---
func (c *Client) ListFolders() (*ListFoldersResponse, error) {
reqURL := fmt.Sprintf("%s/api/folders", c.baseURL)
httpReq, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Accept", "application/json")
resp, err := c.apiClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to list folders: %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 ListFoldersResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &result, nil
}
func (c *Client) GetFolder(folderID string) (*Folder, error) {
reqURL := fmt.Sprintf("%s/api/folders/%s", c.baseURL, url.QueryEscape(folderID))
httpReq, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Accept", "application/json")
resp, err := c.apiClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to get folder: %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 struct {
Data Folder `json:"Data"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &result.Data, nil
}
func (c *Client) CreateFolder(req CreateFolderRequest) (*Folder, error) {
if req.Name == "" {
return nil, fmt.Errorf("folder name is required")
}
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
reqURL := fmt.Sprintf("%s/api/folders", c.baseURL)
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(body))
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 create folder: %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 struct {
Data Folder `json:"Data"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &result.Data, nil
}
func (c *Client) UpdateFolder(folderID string, req UpdateFolderRequest) (*Folder, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
reqURL := fmt.Sprintf("%s/api/folders/%s", c.baseURL, url.QueryEscape(folderID))
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(body))
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 update folder: %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 struct {
Data Folder `json:"Data"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &result.Data, nil
}
func (c *Client) DeleteFolder(folderID string) error {
reqURL := fmt.Sprintf("%s/api/folders/%s", c.baseURL, url.QueryEscape(folderID))
httpReq, err := http.NewRequest("POST", reqURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := c.apiClient.Do(httpReq)
if err != nil {
return fmt.Errorf("failed to delete folder: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("delete failed (status %d): %s", resp.StatusCode, string(body))
}
return nil
}
// --- Labels ---
func (c *Client) ListLabels() (*ListLabelsResponse, error) {
reqURL := fmt.Sprintf("%s/api/labels", c.baseURL)
httpReq, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Accept", "application/json")
resp, err := c.apiClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to list labels: %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 ListLabelsResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &result, nil
}
func (c *Client) CreateLabel(req CreateLabelRequest) (*Label, error) {
if req.Name == "" {
return nil, fmt.Errorf("label name is required")
}
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
reqURL := fmt.Sprintf("%s/api/labels", c.baseURL)
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(body))
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 create label: %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 struct {
Data Label `json:"Data"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &result.Data, nil
}
func (c *Client) UpdateLabel(labelID string, req UpdateLabelRequest) (*Label, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
reqURL := fmt.Sprintf("%s/api/labels/%s", c.baseURL, url.QueryEscape(labelID))
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(body))
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 update label: %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 struct {
Data Label `json:"Data"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &result.Data, nil
}
func (c *Client) DeleteLabel(labelID string) error {
reqURL := fmt.Sprintf("%s/api/labels/%s", c.baseURL, url.QueryEscape(labelID))
httpReq, err := http.NewRequest("POST", reqURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := c.apiClient.Do(httpReq)
if err != nil {
return fmt.Errorf("failed to delete label: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("delete failed (status %d): %s", resp.StatusCode, string(body))
}
return nil
}
// --- Message Labels ---
func (c *Client) ApplyLabel(messageID, labelID string) error {
if messageID == "" || labelID == "" {
return fmt.Errorf("message ID and label ID are required")
}
body := map[string]string{
"LabelID": labelID,
}
jsonBody, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
reqURL := fmt.Sprintf("%s/api/messages/%s/setlabel", c.baseURL, url.QueryEscape(messageID))
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody))
if err != nil {
return 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 fmt.Errorf("failed to apply label: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("apply label failed (status %d): %s", resp.StatusCode, string(body))
}
return nil
}
func (c *Client) RemoveLabel(messageID, labelID string) error {
if messageID == "" || labelID == "" {
return fmt.Errorf("message ID and label ID are required")
}
body := map[string]string{
"LabelID": labelID,
}
jsonBody, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
reqURL := fmt.Sprintf("%s/api/messages/%s/clearlabel", c.baseURL, url.QueryEscape(messageID))
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody))
if err != nil {
return 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 fmt.Errorf("failed to remove label: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("remove label failed (status %d): %s", resp.StatusCode, string(body))
}
return nil
}

58
internal/labels/types.go Normal file
View File

@@ -0,0 +1,58 @@
package labels
// Folder represents a ProtonMail folder (system or custom)
type Folder struct {
ID string `json:"ID"`
Name string `json:"Name"`
Type int `json:"Type"`
MessageCount int `json:"MessageCount,omitempty"`
ParentID string `json:"ParentID,omitempty"`
SortOrder int `json:"SortOrder,omitempty"`
}
// Label represents a user-created label/tag
type Label struct {
ID string `json:"ID"`
Name string `json:"Name"`
Color string `json:"Color,omitempty"`
}
// CreateFolderRequest for creating a new folder
type CreateFolderRequest struct {
Name string `json:"Name"`
ParentID string `json:"ParentID,omitempty"`
}
// UpdateFolderRequest for updating a folder
type UpdateFolderRequest struct {
Name string `json:"Name,omitempty"`
SortOrder *int `json:"SortOrder,omitempty"`
}
// CreateLabelRequest for creating a new label
type CreateLabelRequest struct {
Name string `json:"Name"`
Color string `json:"Color,omitempty"`
}
// UpdateLabelRequest for updating a label
type UpdateLabelRequest struct {
Name string `json:"Name,omitempty"`
Color *string `json:"Color,omitempty"`
}
// LabelMessageRequest for applying/removing labels from messages
type LabelMessageRequest struct {
MessageID string `json:"MessageID"`
LabelID string `json:"LabelID"`
}
// ListFoldersResponse for listing folders
type ListFoldersResponse struct {
Folders []Folder `json:"Folders"`
}
// ListLabelsResponse for listing labels
type ListLabelsResponse struct {
Labels []Label `json:"Labels"`
}

View File

@@ -0,0 +1,37 @@
# FRE-4762 Verification Complete
**Issue:** FRE-4762 — Fix API endpoint paths and HTTP methods to match ProtonMail contract
**Status:****DONE**
## Verification Summary
### Review Completed By
- **Reviewer:** Code Reviewer (f274248f-c47e-4f79-98ad-45919d951aa0)
- **Date:** 2026-05-12T03:24:53Z
- **Document:** `/home/mike/code/FrenoCorp/agents/code-reviewer/reviews/FRE-4762-review.md`
### Findings Verified
| Severity | Count | Details |
|----------|-------|---------|
| P1 Critical | 0 | None |
| P2 High | 1 | ListMessages uses POST with method override (non-blocking, known pattern) |
| P3 Minor | 2 | Redundant Body field, UpdateDraft structure |
### Contract Compliance ✅
- ✅ All endpoint paths use `/mail/v4/` prefix
- ✅ HTTP methods properly used (GET, POST, PUT, DELETE)
- ✅ Response structures match API spec
- ✅ Error handling consistent and proper
- ✅ Resource cleanup correct
## Final Disposition
**Status:** `done`
The implementation has been reviewed, approved, and verified against the go-proton-api v4 contract. All acceptance criteria met.
---
*Generated: 2026-05-12T03:35:00Z*

View File

@@ -7,6 +7,8 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"os"
"time"
"github.com/frenocorp/pop/internal/api" "github.com/frenocorp/pop/internal/api"
) )
@@ -14,6 +16,7 @@ import (
type Client struct { type Client struct {
apiClient *api.ProtonMailClient apiClient *api.ProtonMailClient
baseURL string baseURL string
pgpService *PGPService
} }
func NewClient(apiClient *api.ProtonMailClient) *Client { func NewClient(apiClient *api.ProtonMailClient) *Client {
@@ -23,33 +26,45 @@ func NewClient(apiClient *api.ProtonMailClient) *Client {
} }
} }
func (c *Client) SetPGPService(svc *PGPService) {
c.pgpService = svc
}
func (c *Client) ListMessages(req ListMessagesRequest) (*ListMessagesResponse, error) { func (c *Client) ListMessages(req ListMessagesRequest) (*ListMessagesResponse, error) {
params := url.Values{} body := map[string]interface{}{
params.Set("Page", fmt.Sprintf("%d", req.Page)) "Page": req.Page,
params.Set("PageSize", fmt.Sprintf("%d", req.PageSize)) "PageSize": req.PageSize,
params.Set("Passphrase", req.Passphrase) "Passphrase": req.Passphrase,
}
if req.Folder != FolderInbox { if req.Folder != FolderInbox {
params.Set("Type", fmt.Sprintf("%d", req.Folder)) body["Type"] = int(req.Folder)
} }
if req.Starred != nil { if req.Starred != nil {
params.Set("Starred", fmt.Sprintf("%t", *req.Starred)) body["Starred"] = *req.Starred
} }
if req.Read != nil { if req.Read != nil {
params.Set("Read", fmt.Sprintf("%t", *req.Read)) body["Read"] = *req.Read
} }
if req.Since > 0 { if req.Since > 0 {
params.Set("Since", fmt.Sprintf("%d", req.Since)) body["Since"] = req.Since
} }
reqURL := fmt.Sprintf("%s/api/messages?%s", c.baseURL, params.Encode()) jsonBody, err := json.Marshal(body)
httpReq, err := http.NewRequest("GET", reqURL, nil) if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
reqURL := fmt.Sprintf("%s/mail/v4/messages", c.baseURL)
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("X-HTTP-Method-Override", "GET")
resp, err := c.apiClient.Do(httpReq) resp, err := c.apiClient.Do(httpReq)
if err != nil { if err != nil {
@@ -57,13 +72,13 @@ func (c *Client) ListMessages(req ListMessagesRequest) (*ListMessagesResponse, e
} }
defer resp.Body.Close() defer resp.Body.Close()
body, err := io.ReadAll(resp.Body) respBody, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err) return nil, fmt.Errorf("failed to read response: %w", err)
} }
var result ListMessagesResponse var result ListMessagesResponse
if err := json.Unmarshal(body, &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)
} }
@@ -71,10 +86,11 @@ 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) {
params := url.Values{} var result struct {
params.Set("Passphrase", passphrase) Message Message `json:"Message"`
}
reqURL := fmt.Sprintf("%s/api/messages/%s?%s", c.baseURL, url.QueryEscape(messageID), params.Encode()) reqURL := fmt.Sprintf("%s/mail/v4/messages/%s", c.baseURL, url.QueryEscape(messageID))
httpReq, err := http.NewRequest("GET", reqURL, nil) httpReq, err := http.NewRequest("GET", reqURL, nil)
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)
@@ -86,50 +102,58 @@ func (c *Client) GetMessage(messageID string, passphrase string) (*Message, erro
} }
defer resp.Body.Close() defer resp.Body.Close()
body, err := io.ReadAll(resp.Body) respBody, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err) return nil, fmt.Errorf("failed to read response: %w", err)
} }
var result struct { if err := json.Unmarshal(respBody, &result); err != nil {
Data Message `json:"Data"`
}
if err := json.Unmarshal(body, &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 {
formData := url.Values{} payload := map[string]interface{}{
formData.Set("Type", "0") "Type": MessageTypeRegular,
formData.Set("Passphrase", req.Passphrase) "Passphrase": req.Passphrase,
formData.Set("Subject", req.Subject) "Subject": req.Subject,
formData.Set("HTML", fmt.Sprintf("%t", req.HTML)) "HTML": req.HTML,
"To": req.To,
}
toJSON, _ := json.Marshal(req.To) if req.Body != "" {
formData.Set("To", string(toJSON)) if c.pgpService != nil {
encrypted, err := c.pgpService.EncryptBody(req.Body, req.Passphrase)
if err != nil {
return fmt.Errorf("failed to encrypt message body: %w", err)
}
payload["BodyEnc"] = encrypted
} else {
payload["Body"] = req.Body
}
}
if len(req.CC) > 0 { if len(req.CC) > 0 {
ccJSON, _ := json.Marshal(req.CC) payload["CC"] = req.CC
formData.Set("CC", string(ccJSON))
} }
if len(req.BCC) > 0 { if len(req.BCC) > 0 {
bccJSON, _ := json.Marshal(req.BCC) payload["BCC"] = req.BCC
formData.Set("BCC", string(bccJSON))
} }
bodyData := req.Body jsonBody, err := json.Marshal(payload)
formData.Set("Body", bodyData) if err != nil {
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.NewBufferString(formData.Encode())) 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 {
@@ -145,14 +169,22 @@ func (c *Client) Send(req SendRequest) error {
return nil return nil
} }
func (c *Client) MoveToTrash(messageID string) error { func (c *Client) MoveToTrash(messageID string, passphrase string) error {
formData := url.Values{} body := map[string]string{
reqURL := fmt.Sprintf("%s/api/messages/%s/movetotrash", c.baseURL, url.QueryEscape(messageID)) "Passphrase": passphrase,
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 {
@@ -169,8 +201,8 @@ func (c *Client) MoveToTrash(messageID 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)
} }
@@ -190,32 +222,33 @@ func (c *Client) PermanentlyDelete(messageID string) error {
} }
func (c *Client) SaveDraft(draft Draft, passphrase string) (string, error) { func (c *Client) SaveDraft(draft Draft, passphrase string) (string, error) {
formData := url.Values{} body := map[string]interface{}{
formData.Set("Type", "2") "Type": MessageTypeDraft,
formData.Set("Passphrase", passphrase) "Passphrase": passphrase,
formData.Set("Subject", draft.Subject) "Subject": draft.Subject,
"To": draft.To,
toJSON, _ := json.Marshal(draft.To) "Body": draft.Body,
formData.Set("To", string(toJSON)) }
if len(draft.CC) > 0 { if len(draft.CC) > 0 {
ccJSON, _ := json.Marshal(draft.CC) body["CC"] = draft.CC
formData.Set("CC", string(ccJSON))
} }
if len(draft.BCC) > 0 { if len(draft.BCC) > 0 {
bccJSON, _ := json.Marshal(draft.BCC) body["BCC"] = draft.BCC
formData.Set("BCC", string(bccJSON))
} }
formData.Set("Body", draft.Body) jsonBody, err := json.Marshal(body)
if err != nil {
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.NewBufferString(formData.Encode())) 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 {
@@ -223,44 +256,48 @@ func (c *Client) SaveDraft(draft Draft, passphrase string) (string, error) {
} }
defer resp.Body.Close() defer resp.Body.Close()
body, err := io.ReadAll(resp.Body) respBody, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to read response: %w", err) return "", fmt.Errorf("failed to read response: %w", err)
} }
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(body, &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 {
formData := url.Values{} body := map[string]interface{}{
formData.Set("Passphrase", passphrase) "Message": map[string]interface{}{
formData.Set("Subject", draft.Subject) "Passphrase": passphrase,
"Subject": draft.Subject,
toJSON, _ := json.Marshal(draft.To) "To": draft.To,
formData.Set("To", string(toJSON)) "Body": draft.Body,
},
if len(draft.CC) > 0 {
ccJSON, _ := json.Marshal(draft.CC)
formData.Set("CC", string(ccJSON))
} }
formData.Set("Body", draft.Body) if len(draft.CC) > 0 {
body["Message"].(map[string]interface{})["CC"] = draft.CC
}
reqURL := fmt.Sprintf("%s/api/messages/%s", c.baseURL, url.QueryEscape(messageID)) jsonBody, err := json.Marshal(body)
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBufferString(formData.Encode())) 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("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 {
@@ -276,14 +313,22 @@ func (c *Client) UpdateDraft(messageID string, draft Draft, passphrase string) e
return nil return nil
} }
func (c *Client) SendDraft(messageID string) error { func (c *Client) SendDraft(messageID string, passphrase string) error {
formData := url.Values{} body := map[string]string{
reqURL := fmt.Sprintf("%s/api/messages/%s/send", c.baseURL, url.QueryEscape(messageID)) "Passphrase": passphrase,
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 {
@@ -310,17 +355,24 @@ func (c *Client) ListDrafts(page int, pageSize int, passphrase string) (*ListMes
} }
func (c *Client) SearchMessages(req SearchRequest) (*SearchResponse, error) { func (c *Client) SearchMessages(req SearchRequest) (*SearchResponse, error) {
params := url.Values{} body := map[string]interface{}{
params.Set("Query", req.Query) "Query": req.Query,
params.Set("Page", fmt.Sprintf("%d", req.Page)) "Page": req.Page,
params.Set("PageSize", fmt.Sprintf("%d", req.PageSize)) "PageSize": req.PageSize,
params.Set("Passphrase", req.Passphrase) "Passphrase": req.Passphrase,
}
reqURL := fmt.Sprintf("%s/api/messages/search?%s", c.baseURL, params.Encode()) jsonBody, err := json.Marshal(body)
httpReq, err := http.NewRequest("GET", reqURL, nil) if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
reqURL := fmt.Sprintf("%s/mail/v4/messages/search", c.baseURL)
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 {
@@ -328,15 +380,347 @@ func (c *Client) SearchMessages(req SearchRequest) (*SearchResponse, error) {
} }
defer resp.Body.Close() defer resp.Body.Close()
body, err := io.ReadAll(resp.Body) respBody, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err) return nil, fmt.Errorf("failed to read response: %w", err)
} }
var result SearchResponse var result SearchResponse
if err := json.Unmarshal(body, &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, 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

File diff suppressed because it is too large Load Diff

View File

@@ -3,13 +3,16 @@ package mail
import ( import (
"crypto/rand" "crypto/rand"
"fmt" "fmt"
"sync"
"github.com/ProtonMail/gopenpgp/v2/crypto" "github.com/ProtonMail/gopenpgp/v2/crypto"
) )
type PGPKeyRing struct { type PGPKeyRing struct {
mu sync.Mutex
PrivateKey *crypto.Key PrivateKey *crypto.Key
PublicKey []byte PublicKey []byte
PrivateKeyData []byte
} }
type PGPService struct { type PGPService struct {
@@ -22,33 +25,159 @@ 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),
}, },
}, nil }, nil
} }
func (s *PGPService) Encrypt(plaintext string, recipientPublicKey *crypto.Key) (string, error) { func (s *PGPService) Encrypt(plaintext string, recipientPublicKey *crypto.Key) (string, error) {
return plaintext, nil pgpMessage := crypto.NewPlainMessage([]byte(plaintext))
recipientKeyRing, err := crypto.NewKeyRing(recipientPublicKey)
if err != nil {
return "", fmt.Errorf("failed to create recipient key ring: %w", err)
}
encrypted, err := recipientKeyRing.Encrypt(pgpMessage, nil)
if err != nil {
return "", fmt.Errorf("failed to encrypt: %w", err)
}
armored, err := encrypted.GetArmored()
if err != nil {
return "", fmt.Errorf("failed to armor encrypted message: %w", err)
}
return armored, nil
}
func (s *PGPService) EncryptBody(plaintext string, passphrase string) (string, error) {
pgpMessage := crypto.NewPlainMessage([]byte(plaintext))
pubKeyBytes, err := s.keyRing.PrivateKey.GetPublicKey()
if err != nil {
return "", fmt.Errorf("failed to get public key: %w", err)
}
pubKey, err := crypto.NewKey(pubKeyBytes)
if err != nil {
return "", fmt.Errorf("failed to parse public key: %w", err)
}
recipientKeyRing, err := crypto.NewKeyRing(pubKey)
if err != nil {
return "", fmt.Errorf("failed to create encryption key ring: %w", err)
}
signingKeyRing, err := s.getUnlockedKeyRing(passphrase)
if err != nil {
return "", fmt.Errorf("failed to create signing key ring: %w", err)
}
encrypted, err := recipientKeyRing.Encrypt(pgpMessage, signingKeyRing)
if err != nil {
return "", fmt.Errorf("failed to encrypt body: %w", err)
}
armored, err := encrypted.GetArmored()
if err != nil {
return "", fmt.Errorf("failed to armor encrypted body: %w", err)
}
return armored, nil
} }
func (s *PGPService) EncryptAndSign(plaintext string, recipientPublicKey *crypto.Key, passphrase string) (string, error) { func (s *PGPService) EncryptAndSign(plaintext string, recipientPublicKey *crypto.Key, passphrase string) (string, error) {
return s.Encrypt(plaintext, recipientPublicKey) pgpMessage := crypto.NewPlainMessage([]byte(plaintext))
recipientKeyRing, err := crypto.NewKeyRing(recipientPublicKey)
if err != nil {
return "", fmt.Errorf("failed to create recipient key ring: %w", err)
}
signingKeyRing, err := s.getUnlockedKeyRing(passphrase)
if err != nil {
return "", fmt.Errorf("failed to create signing key ring: %w", err)
}
encrypted, err := recipientKeyRing.Encrypt(pgpMessage, signingKeyRing)
if err != nil {
return "", fmt.Errorf("failed to encrypt and sign: %w", err)
}
armored, err := encrypted.GetArmored()
if err != nil {
return "", fmt.Errorf("failed to armor encrypted message: %w", err)
}
return armored, nil
}
func (s *PGPService) getUnlockedKeyRing(passphrase string) (*crypto.KeyRing, error) {
s.keyRing.mu.Lock()
key, err := crypto.NewKeyFromArmored(string(s.keyRing.PrivateKeyData))
s.keyRing.mu.Unlock()
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %w", err)
}
if passphrase != "" {
isLocked, err := key.IsLocked()
if err != nil {
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
}
}
return crypto.NewKeyRing(key)
} }
func (s *PGPService) Decrypt(encrypted string, passphrase string) (string, error) { func (s *PGPService) Decrypt(encrypted string, passphrase string) (string, error) {
return encrypted, nil pgpMessage, err := crypto.NewPGPMessageFromArmored(encrypted)
if err != nil {
return "", fmt.Errorf("failed to parse encrypted message: %w", err)
}
decryptionKeyRing, err := s.getUnlockedKeyRing(passphrase)
if err != nil {
return "", fmt.Errorf("failed to create decryption key ring: %w", err)
}
decrypted, err := decryptionKeyRing.Decrypt(pgpMessage, nil, 0)
if err != nil {
return "", fmt.Errorf("failed to decrypt: %w", err)
}
return decrypted.GetString(), nil
} }
func (s *PGPService) GenerateKeyPair(email string, passphrase string) (privateKey, publicKey string, err error) { func (s *PGPService) GenerateKeyPair(email string, passphrase string) (privateKey, publicKey string, err error) {
key, err := crypto.GenerateKey(email, passphrase, "RSA", 2048) key, err := crypto.GenerateKey(email, passphrase, "RSA", 4096)
if err != nil { if err != nil {
return "", "", fmt.Errorf("failed to generate key pair: %w", err) return "", "", fmt.Errorf("failed to generate key pair: %w", err)
} }
@@ -63,9 +192,17 @@ 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)
}
return string(privateArmor), pubArmor, nil pubArmor, err := pubKey.Armor()
if err != nil {
return "", "", fmt.Errorf("failed to armor public key: %w", err)
}
return privateArmor, pubArmor, nil
} }
func (s *PGPService) GetFingerprint() (string, error) { func (s *PGPService) GetFingerprint() (string, error) {
@@ -76,8 +213,36 @@ func (s *PGPService) GetFingerprint() (string, error) {
return fingerprint, nil return fingerprint, nil
} }
func (s *PGPService) ZeroPrivateKeyData() {
if s.keyRing == nil {
return
}
s.keyRing.mu.Lock()
defer s.keyRing.mu.Unlock()
for i := range s.keyRing.PrivateKeyData {
s.keyRing.PrivateKeyData[i] = 0
}
}
func (s *PGPService) SignData(data []byte, passphrase string) (string, error) { func (s *PGPService) SignData(data []byte, passphrase string) (string, error) {
return string(data), nil pgpMessage := crypto.NewPlainMessage(data)
signingKeyRing, err := s.getUnlockedKeyRing(passphrase)
if err != nil {
return "", fmt.Errorf("failed to create signing key ring: %w", err)
}
signed, err := signingKeyRing.SignDetached(pgpMessage)
if err != nil {
return "", fmt.Errorf("failed to sign data: %w", err)
}
armored, err := signed.GetArmored()
if err != nil {
return "", fmt.Errorf("failed to armor signed data: %w", err)
}
return armored, nil
} }
func (s *PGPService) EncryptAttachment(data []byte, recipientPublicKey *crypto.Key) (*Attachment, error) { func (s *PGPService) EncryptAttachment(data []byte, recipientPublicKey *crypto.Key) (*Attachment, error) {
@@ -86,16 +251,30 @@ func (s *PGPService) EncryptAttachment(data []byte, recipientPublicKey *crypto.K
return nil, fmt.Errorf("failed to generate symmetric key: %w", err) return nil, fmt.Errorf("failed to generate symmetric key: %w", err)
} }
encData := make([]byte, len(data)) pgpMessage := crypto.NewPlainMessage(data)
copy(encData, data)
encKey := make([]byte, len(symKey)) sk, err := crypto.NewSessionKeyFromToken(symKey, "aes256").Encrypt(pgpMessage)
copy(encKey, symKey) if err != nil {
return nil, fmt.Errorf("failed to encrypt attachment: %w", err)
}
// Encrypt the symmetric key with recipient's public key
recipientKeyRing, err := crypto.NewKeyRing(recipientPublicKey)
if err != nil {
return nil, fmt.Errorf("failed to create recipient key ring: %w", err)
}
encryptedSymKey, err := recipientKeyRing.EncryptSessionKey(
crypto.NewSessionKeyFromToken(symKey, "aes256"),
)
if err != nil {
return nil, fmt.Errorf("failed to encrypt symmetric key: %w", err)
}
return &Attachment{ return &Attachment{
DataEnc: string(encData), DataEnc: string(sk),
Keys: []AttachmentKey{{ Keys: []AttachmentKey{{
DataEnc: string(encKey), DataEnc: string(encryptedSymKey),
}}, }},
}, nil }, nil
} }
@@ -105,8 +284,20 @@ func (s *PGPService) DecryptAttachment(attachment *Attachment, passphrase string
return nil, fmt.Errorf("no keys available for attachment decryption") return nil, fmt.Errorf("no keys available for attachment decryption")
} }
decrypted := make([]byte, len(attachment.DataEnc)) decryptionKeyRing, err := s.getUnlockedKeyRing(passphrase)
copy(decrypted, attachment.DataEnc) if err != nil {
return nil, fmt.Errorf("failed to create decryption key ring: %w", err)
}
return decrypted, nil sk, err := decryptionKeyRing.DecryptSessionKey([]byte(attachment.Keys[0].DataEnc))
if err != nil {
return nil, fmt.Errorf("failed to decrypt symmetric key: %w", err)
}
decrypted, err := sk.Decrypt([]byte(attachment.DataEnc))
if err != nil {
return nil, fmt.Errorf("failed to decrypt attachment: %w", err)
}
return decrypted.GetBinary(), nil
} }

557
internal/mail/pgp_test.go Normal file
View 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))
}
}

View File

@@ -12,6 +12,11 @@ const (
FolderSpam Folder = 5 FolderSpam Folder = 5
) )
const (
MessageTypeRegular = "0"
MessageTypeDraft = "2"
)
func (f Folder) Name() string { func (f Folder) Name() string {
names := map[Folder]string{ names := map[Folder]string{
FolderInbox: "Inbox", FolderInbox: "Inbox",
@@ -48,10 +53,10 @@ type Message struct {
} }
func (m *Message) Folder() Folder { func (m *Message) Folder() Folder {
if m.Type == 2 { if m.Type == int(FolderDraft) {
return FolderDraft return FolderDraft
} }
if m.Type == 3 { if m.Type == int(FolderSent) {
return FolderSent return FolderSent
} }
return FolderInbox return FolderInbox
@@ -73,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"`
@@ -120,6 +132,8 @@ type SendRequest struct {
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"`
} }
@@ -135,3 +149,118 @@ type SearchResponse struct {
Total int `json:"Total"` Total int `json:"Total"`
Messages []Message `json:"Messages"` Messages []Message `json:"Messages"`
} }
// Conversation represents a threaded conversation (email thread)
type Conversation struct {
ConversationID string `json:"ConversationID"`
Subject string `json:"Subject"`
MessageCount int `json:"MessageCount"`
LastMessage *Message `json:"LastMessage"`
Participants []Recipient `json:"Participants"`
}
type ConversationResponse struct {
Total int `json:"Total"`
Conversations []Conversation `json:"Conversations"`
}
type GetConversationRequest struct {
ConversationID string `json:"ConversationID"`
Page int `json:"Page"`
PageSize int `json:"PageSize"`
Passphrase string `json:"Passphrase"`
}
type GetConversationResponse struct {
ConversationID string `json:"ConversationID"`
Subject string `json:"Subject"`
MessageCount int `json:"MessageCount"`
Messages []Message `json:"Messages"`
Participants []Recipient `json:"Participants"`
}
// BulkRequest represents a batch operation on multiple messages
type BulkRequest struct {
MessageIDs []string `json:"MessageIDs"`
Passphrase string `json:"Passphrase"`
}
type BulkResponse struct {
SuccessCount int `json:"SuccessCount"`
Total int `json:"Total"`
Errors []BulkError `json:"Errors,omitempty"`
}
type BulkError struct {
MessageID string `json:"MessageID"`
Error string `json:"Error"`
}
// ExportFormat represents the format for exporting messages
type ExportFormat int
const (
ExportFormatJSON ExportFormat = iota
ExportFormatMBOX
ExportFormatEMail
)
func (f ExportFormat) String() string {
names := map[ExportFormat]string{
ExportFormatJSON: "json",
ExportFormatMBOX: "mbox",
ExportFormatEMail: "eml",
}
if name, ok := names[f]; ok {
return name
}
return "json"
}
// ExportRequest represents a message export request
type ExportRequest struct {
MessageIDs []string `json:"MessageIDs,omitempty"`
Folder Folder `json:"Folder,omitempty"`
Format ExportFormat `json:"Format"`
Since int64 `json:"Since,omitempty"`
Before int64 `json:"Before,omitempty"`
Search string `json:"Search,omitempty"`
Passphrase string `json:"Passphrase"`
}
// ExportedMessage represents a message ready for export
type ExportedMessage struct {
MessageID string `json:"message_id"`
ConversationID string `json:"conversation_id"`
From Recipient `json:"from"`
To []Recipient `json:"to"`
CC []Recipient `json:"cc,omitempty"`
Subject string `json:"subject"`
Body string `json:"body"`
HTML bool `json:"html"`
Date string `json:"date"`
Starred bool `json:"starred"`
Read bool `json:"read"`
Attachments []Attachment `json:"attachments,omitempty"`
}
// ImportRequest represents a message import request
type ImportRequest struct {
FilePath string `json:"FilePath"`
Format ExportFormat `json:"Format"`
Folder Folder `json:"Folder,omitempty"`
Passphrase string `json:"Passphrase"`
}
type ImportResponse struct {
ImportedCount int `json:"ImportedCount"`
Total int `json:"Total"`
Errors []BulkError `json:"Errors,omitempty"`
}
// DraftAutoSaveConfig holds auto-save settings for drafts
type DraftAutoSaveConfig struct {
Enabled bool `json:"enabled"`
Interval int `json:"interval_seconds"`
LastSaved int64 `json:"last_saved_timestamp"`
}

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

@@ -0,0 +1,465 @@
package pgp
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
openpgp "github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/frenocorp/pop/internal/config"
)
// ExternalKey represents an imported external PGP key.
type ExternalKey struct {
KeyID string `json:"key_id"`
Fingerprint string `json:"fingerprint"`
Emails []string `json:"emails"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at,omitempty"`
IsSubkey bool `json:"is_subkey"`
CanEncrypt bool `json:"can_encrypt"`
CanSign bool `json:"can_sign"`
TrustLevel string `json:"trust_level"`
ArmorFile string `json:"armor_file"`
}
// KeyStore manages external PGP keys.
type KeyStore struct {
configDir string
keysDir string
keysFile string
}
// NewKeyStore creates a new PGP key store.
func NewKeyStore() (*KeyStore, error) {
cfg := config.NewConfigManager()
configDir := cfg.ConfigDir()
keysDir := filepath.Join(configDir, "pgp_keys")
if err := os.MkdirAll(keysDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create PGP keys directory: %w", err)
}
return &KeyStore{
configDir: configDir,
keysDir: keysDir,
keysFile: filepath.Join(configDir, "pgp_keys.json"),
}, nil
}
// ImportKey imports an external PGP key from armored ASCII text.
func (ks *KeyStore) ImportKey(armor string, trustLevel string) (*ExternalKey, error) {
if trustLevel == "" {
trustLevel = "unknown"
}
pgpKey, err := openpgp.NewKeyFromArmored(armor)
if err != nil {
return nil, fmt.Errorf("failed to parse PGP key: %w", err)
}
fingerprint := pgpKey.GetFingerprint()
keyID := fingerprint[len(fingerprint)-8:]
emails := []string{}
if entity := pgpKey.GetEntity(); entity != nil {
for _, uid := range entity.Identities {
if uid != nil && uid.UserId != nil {
emails = append(emails, uid.UserId.Email)
}
}
}
expiresAt := ""
if pgpKey.GetEntity() != nil {
expiresAt = time.Unix(int64(pgpKey.GetEntity().PrimaryKey.CreationTime.Unix()), 0).UTC().Format(time.RFC3339)
}
key := &ExternalKey{
KeyID: keyID,
Fingerprint: fingerprint,
Emails: emails,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
ExpiresAt: expiresAt,
IsSubkey: false,
CanEncrypt: pgpKey.CanEncrypt(),
CanSign: pgpKey.IsPrivate(),
TrustLevel: trustLevel,
ArmorFile: filepath.Join(ks.keysDir, keyID+".asc"),
}
if err := os.WriteFile(key.ArmorFile, []byte(armor), 0600); err != nil {
return nil, fmt.Errorf("failed to write key file: %w", err)
}
if err := ks.saveKeyMetadata(key); err != nil {
os.Remove(key.ArmorFile)
return nil, err
}
return key, nil
}
// ImportKeyFromFile imports a PGP key from a file containing armored ASCII.
func (ks *KeyStore) ImportKeyFromFile(filePath string, trustLevel string) (*ExternalKey, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to read key file: %w", err)
}
return ks.ImportKey(string(data), trustLevel)
}
// ListKeys returns all imported external keys.
func (ks *KeyStore) ListKeys() ([]ExternalKey, error) {
data, err := os.ReadFile(ks.keysFile)
if err != nil {
if os.IsNotExist(err) {
return []ExternalKey{}, nil
}
return nil, fmt.Errorf("failed to read keys metadata: %w", err)
}
var keys []ExternalKey
if err := json.Unmarshal(data, &keys); err != nil {
return nil, fmt.Errorf("failed to parse keys metadata: %w", err)
}
return keys, nil
}
// GetKey retrieves a key by key ID or fingerprint.
func (ks *KeyStore) GetKey(identifier string) (*ExternalKey, error) {
keys, err := ks.ListKeys()
if err != nil {
return nil, err
}
for _, key := range keys {
if key.KeyID == identifier || key.Fingerprint == identifier {
return &key, nil
}
}
return nil, fmt.Errorf("key %q not found", identifier)
}
// RemoveKey removes an external key by key ID or fingerprint.
func (ks *KeyStore) RemoveKey(identifier string) error {
keys, err := ks.ListKeys()
if err != nil {
return err
}
var keyToRemove *ExternalKey
newKeys := make([]ExternalKey, 0, len(keys))
for i := range keys {
if keys[i].KeyID == identifier || keys[i].Fingerprint == identifier {
keyToRemove = &keys[i]
continue
}
newKeys = append(newKeys, keys[i])
}
if keyToRemove == nil {
return fmt.Errorf("key %q not found", identifier)
}
if keyToRemove.ArmorFile != "" {
os.Remove(keyToRemove.ArmorFile)
}
return ks.writeKeysMetadata(newKeys)
}
// GetKeyArmor returns the armored ASCII representation of a key.
func (ks *KeyStore) GetKeyArmor(identifier string) (string, error) {
key, err := ks.GetKey(identifier)
if err != nil {
return "", err
}
data, err := os.ReadFile(key.ArmorFile)
if err != nil {
return "", fmt.Errorf("failed to read key armor: %w", err)
}
return string(data), nil
}
// EncryptData encrypts plaintext using a public key.
func (ks *KeyStore) EncryptData(identifier, plaintext string) (string, error) {
armor, err := ks.GetKeyArmor(identifier)
if err != nil {
return "", err
}
key, err := openpgp.NewKeyFromArmored(armor)
if err != nil {
return "", fmt.Errorf("failed to parse key for encryption: %w", err)
}
if !key.CanEncrypt() {
return "", fmt.Errorf("key %s cannot be used for encryption", identifier)
}
plainMsg := openpgp.NewPlainMessageFromString(plaintext)
keyRing, err := openpgp.NewKeyRing(key)
if err != nil {
return "", fmt.Errorf("failed to create keyring: %w", err)
}
encryptedMsg, err := keyRing.Encrypt(plainMsg, keyRing)
if err != nil {
return "", fmt.Errorf("failed to encrypt data: %w", err)
}
result, err := encryptedMsg.GetArmored()
if err != nil {
return "", fmt.Errorf("failed to get armored encrypted data: %w", err)
}
return result, nil
}
// SignData signs plaintext using a private key.
func (ks *KeyStore) SignData(identifier, plaintext, passphrase string) (string, error) {
armor, err := ks.GetKeyArmor(identifier)
if err != nil {
return "", err
}
key, err := openpgp.NewKeyFromArmored(armor)
if err != nil {
return "", fmt.Errorf("failed to parse key for signing: %w", err)
}
if passphrase != "" {
unlockedKey, unlockErr := key.Unlock([]byte(passphrase))
if unlockErr != nil {
return "", fmt.Errorf("failed to unlock key: %w", unlockErr)
}
key = unlockedKey
}
if !key.IsPrivate() {
return "", fmt.Errorf("key %s cannot be used for signing", identifier)
}
keyRing, err := openpgp.NewKeyRing(key)
if err != nil {
return "", fmt.Errorf("failed to create keyring: %w", err)
}
plainMsg := openpgp.NewPlainMessageFromString(plaintext)
signedMsg, err := keyRing.SignDetached(plainMsg)
if err != nil {
return "", fmt.Errorf("failed to sign data: %w", err)
}
result, err := signedMsg.GetArmored()
if err != nil {
return "", fmt.Errorf("failed to get armored signature: %w", err)
}
return result, nil
}
// DecryptData decrypts PGP-encrypted data using a private key.
func (ks *KeyStore) DecryptData(identifier, encryptedData, passphrase string) (string, error) {
armor, err := ks.GetKeyArmor(identifier)
if err != nil {
return "", err
}
key, err := openpgp.NewKeyFromArmored(armor)
if err != nil {
return "", fmt.Errorf("failed to parse key for decryption: %w", err)
}
if passphrase != "" {
unlockedKey, unlockErr := key.Unlock([]byte(passphrase))
if unlockErr != nil {
return "", fmt.Errorf("failed to unlock key: %w", unlockErr)
}
key = unlockedKey
}
keyRing, err := openpgp.NewKeyRing(key)
if err != nil {
return "", fmt.Errorf("failed to create keyring: %w", err)
}
encryptedMsg, parseErr := openpgp.NewPGPMessageFromArmored(encryptedData)
if parseErr != nil {
return "", fmt.Errorf("failed to parse encrypted message: %w", parseErr)
}
plainMessage, err := keyRing.Decrypt(encryptedMsg, nil, 0)
if err != nil {
return "", fmt.Errorf("failed to decrypt data: %w", err)
}
return string(plainMessage.Data), nil
}
// VerifySignature verifies a detached signature.
func (ks *KeyStore) VerifySignature(keyID, message, signature string) (bool, error) {
armor, err := ks.GetKeyArmor(keyID)
if err != nil {
return false, err
}
key, err := openpgp.NewKeyFromArmored(armor)
if err != nil {
return false, fmt.Errorf("failed to parse key for verification: %w", err)
}
sig, err := openpgp.NewPGPSignatureFromArmored(signature)
if err != nil {
return false, fmt.Errorf("failed to parse signature: %w", err)
}
keyRing, err := openpgp.NewKeyRing(key)
if err != nil {
return false, fmt.Errorf("failed to create keyring: %w", err)
}
plainMsg := openpgp.NewPlainMessage([]byte(message))
err = keyRing.VerifyDetached(plainMsg, sig, 0)
if err != nil {
return false, fmt.Errorf("signature verification failed: %w", err)
}
return true, nil
}
// TrustKey sets the trust level for a key.
func (ks *KeyStore) TrustKey(identifier, trustLevel string) error {
keys, err := ks.ListKeys()
if err != nil {
return err
}
found := false
for i, key := range keys {
if key.KeyID == identifier || key.Fingerprint == identifier {
keys[i].TrustLevel = trustLevel
found = true
break
}
}
if !found {
return fmt.Errorf("key %q not found", identifier)
}
return ks.writeKeysMetadata(keys)
}
// ExportKey exports a key to a file.
func (ks *KeyStore) ExportKey(identifier, outputPath string) error {
armor, err := ks.GetKeyArmor(identifier)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
if err := os.WriteFile(outputPath, []byte(armor), 0600); err != nil {
return fmt.Errorf("failed to write exported key: %w", err)
}
return nil
}
// GetKeyFingerprint returns the fingerprint of a key from armored data.
func GetKeyFingerprint(armor string) (string, error) {
key, err := openpgp.NewKeyFromArmored(armor)
if err != nil {
return "", err
}
return key.GetFingerprint(), nil
}
// ParseKeyInfo extracts key information from armored PGP data without importing.
func ParseKeyInfo(armor string) (*ExternalKey, error) {
key, err := openpgp.NewKeyFromArmored(armor)
if err != nil {
return nil, fmt.Errorf("failed to parse key: %w", err)
}
fingerprint := key.GetFingerprint()
keyID := fingerprint[len(fingerprint)-8:]
emails := []string{}
if entity := key.GetEntity(); entity != nil {
for _, uid := range entity.Identities {
if uid != nil && uid.UserId != nil {
emails = append(emails, uid.UserId.Email)
}
}
}
expiresAt := ""
if entity := key.GetEntity(); entity != nil {
expiresAt = time.Unix(int64(entity.PrimaryKey.CreationTime.Unix()), 0).UTC().Format(time.RFC3339)
}
return &ExternalKey{
KeyID: keyID,
Fingerprint: fingerprint,
Emails: emails,
CreatedAt: expiresAt,
ExpiresAt: expiresAt,
IsSubkey: false,
CanEncrypt: key.CanEncrypt(),
CanSign: key.IsPrivate(),
}, nil
}
func (ks *KeyStore) saveKeyMetadata(key *ExternalKey) error {
keys, err := ks.ListKeys()
if err != nil {
return err
}
for _, k := range keys {
if k.KeyID == key.KeyID {
return fmt.Errorf("key with ID %s already imported", key.KeyID)
}
}
keys = append(keys, *key)
return ks.writeKeysMetadata(keys)
}
func (ks *KeyStore) writeKeysMetadata(keys []ExternalKey) error {
data, err := json.MarshalIndent(keys, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal keys metadata: %w", err)
}
return os.WriteFile(ks.keysFile, data, 0600)
}
// KeyFromReader reads PGP key data from an io.Reader (useful for stdin).
func (ks *KeyStore) KeyFromReader(reader io.Reader, trustLevel string) (*ExternalKey, error) {
data, err := io.ReadAll(reader)
if err != nil {
return nil, fmt.Errorf("failed to read key data: %w", err)
}
content := string(data)
if !strings.Contains(content, "BEGIN PGP PUBLIC KEY BLOCK") &&
!strings.Contains(content, "BEGIN PGP PRIVATE KEY BLOCK") {
return nil, fmt.Errorf("input does not appear to be a PGP key (missing armor header)")
}
return ks.ImportKey(content, trustLevel)
}

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

@@ -0,0 +1,291 @@
package plugin
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/frenocorp/pop/internal/config"
)
// Plugin represents a CLI plugin that can extend Pop's functionality.
type Plugin struct {
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
Author string `json:"author,omitempty"`
Binary string `json:"binary"`
InstalledAt string `json:"installed_at,omitempty"`
Source string `json:"source,omitempty"`
Commands []PluginCommand `json:"commands,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
Enabled bool `json:"enabled"`
}
// PluginCommand represents a command exposed by a plugin.
type PluginCommand struct {
Name string `json:"name"`
Description string `json:"description"`
Usage string `json:"usage,omitempty"`
}
// PluginRegistry manages installed and available plugins.
type PluginRegistry struct {
configDir string
pluginsDir string
registryFile string
}
// NewPluginRegistry creates a new plugin registry.
func NewPluginRegistry() (*PluginRegistry, error) {
cfg := config.NewConfigManager()
configDir := cfg.ConfigDir()
pluginsDir := filepath.Join(configDir, "plugins")
if err := os.MkdirAll(pluginsDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create plugins directory: %w", err)
}
return &PluginRegistry{
configDir: configDir,
pluginsDir: pluginsDir,
registryFile: filepath.Join(configDir, "plugins.json"),
}, nil
}
// ListPlugins returns all installed plugins.
func (pr *PluginRegistry) ListPlugins() ([]Plugin, error) {
data, err := os.ReadFile(pr.registryFile)
if err != nil {
if os.IsNotExist(err) {
return []Plugin{}, nil
}
return nil, fmt.Errorf("failed to read plugins registry: %w", err)
}
var plugins []Plugin
if err := json.Unmarshal(data, &plugins); err != nil {
return nil, fmt.Errorf("failed to parse plugins registry: %w", err)
}
return plugins, nil
}
// GetPlugin retrieves a plugin by name.
func (pr *PluginRegistry) GetPlugin(name string) (*Plugin, error) {
plugins, err := pr.ListPlugins()
if err != nil {
return nil, err
}
for _, p := range plugins {
if p.Name == name {
return &p, nil
}
}
return nil, fmt.Errorf("plugin %q not found", name)
}
// InstallPlugin installs a plugin binary and registers it.
func (pr *PluginRegistry) InstallPlugin(plugin Plugin) error {
if plugin.Name == "" {
return fmt.Errorf("plugin name is required")
}
if plugin.Binary == "" {
return fmt.Errorf("plugin binary path is required")
}
plugins, err := pr.ListPlugins()
if err != nil {
return err
}
for _, p := range plugins {
if p.Name == plugin.Name {
return fmt.Errorf("plugin %q is already installed (use --force to reinstall)", plugin.Name)
}
}
plugin.Binary = filepath.Join(pr.pluginsDir, plugin.Binary)
plugin.InstalledAt = plugin.InstalledAt
if err := os.MkdirAll(pr.pluginsDir, 0755); err != nil {
return fmt.Errorf("failed to create plugins directory: %w", err)
}
if err := os.Chmod(plugin.Binary, 0755); err != nil {
return fmt.Errorf("failed to set executable permission: %w", err)
}
plugins = append(plugins, plugin)
data, err := json.MarshalIndent(plugins, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal plugins registry: %w", err)
}
if err := os.WriteFile(pr.registryFile, data, 0600); err != nil {
return fmt.Errorf("failed to write plugins registry: %w", err)
}
return nil
}
// UninstallPlugin removes a plugin.
func (pr *PluginRegistry) UninstallPlugin(name string) error {
plugins, err := pr.ListPlugins()
if err != nil {
return err
}
var pluginToRemove *Plugin
newPlugins := make([]Plugin, 0, len(plugins))
for _, p := range plugins {
if p.Name == name {
pluginToRemove = &p
continue
}
newPlugins = append(newPlugins, p)
}
if pluginToRemove == nil {
return fmt.Errorf("plugin %q not found", name)
}
if pluginToRemove.Binary != "" {
os.Remove(pluginToRemove.Binary)
}
data, err := json.MarshalIndent(newPlugins, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal plugins registry: %w", err)
}
return os.WriteFile(pr.registryFile, data, 0600)
}
// ExecutePlugin runs a plugin with the given arguments.
func (pr *PluginRegistry) ExecutePlugin(name string, args []string) error {
plugin, err := pr.GetPlugin(name)
if err != nil {
return err
}
if _, err := os.Stat(plugin.Binary); err != nil {
return fmt.Errorf("plugin binary not found at %s: %w", plugin.Binary, err)
}
cmd := exec.Command(plugin.Binary, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(),
"POP_PLUGIN_NAME="+plugin.Name,
"POP_PLUGIN_VERSION="+plugin.Version,
"POP_CONFIG_DIR="+pr.configDir,
)
return cmd.Run()
}
// DiscoverPlugins scans the plugins directory for unregistered binaries.
func (pr *PluginRegistry) DiscoverPlugins() ([]string, error) {
entries, err := os.ReadDir(pr.pluginsDir)
if err != nil {
return nil, err
}
var binaries []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
if strings.HasPrefix(entry.Name(), ".") {
continue
}
if runtime.GOOS == "windows" && !strings.HasSuffix(entry.Name(), ".exe") {
continue
}
if runtime.GOOS != "windows" && strings.HasSuffix(entry.Name(), ".exe") {
continue
}
binaries = append(binaries, entry.Name())
}
return binaries, nil
}
// PluginBinaryPath returns the expected binary path for a plugin name.
func (pr *PluginRegistry) PluginBinaryPath(name string) string {
binary := "pop-" + name
if runtime.GOOS == "windows" {
binary += ".exe"
}
return filepath.Join(pr.pluginsDir, binary)
}
// PluginsDir returns the plugins directory path.
func (pr *PluginRegistry) PluginsDir() string {
return pr.pluginsDir
}
// EnablePlugin enables a plugin by name.
func (pr *PluginRegistry) EnablePlugin(name string) error {
plugins, err := pr.ListPlugins()
if err != nil {
return err
}
found := false
for i, p := range plugins {
if p.Name == name {
plugins[i].Enabled = true
found = true
break
}
}
if !found {
return fmt.Errorf("plugin %q not found", name)
}
data, err := json.MarshalIndent(plugins, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal plugins registry: %w", err)
}
return os.WriteFile(pr.registryFile, data, 0600)
}
// DisablePlugin disables a plugin by name.
func (pr *PluginRegistry) DisablePlugin(name string) error {
plugins, err := pr.ListPlugins()
if err != nil {
return err
}
found := false
for i, p := range plugins {
if p.Name == name {
plugins[i].Enabled = false
found = true
break
}
}
if !found {
return fmt.Errorf("plugin %q not found", name)
}
data, err := json.MarshalIndent(plugins, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal plugins registry: %w", err)
}
return os.WriteFile(pr.registryFile, data, 0600)
}

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

@@ -0,0 +1,375 @@
package webhook
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"sync"
"time"
"github.com/frenocorp/pop/internal/config"
)
// EventType represents a mail event that can trigger a webhook.
type EventType string
const (
// EventReceived is triggered when a new message arrives.
EventReceived EventType = "mail.received"
// EventSent is triggered when a message is sent.
EventSent EventType = "mail.sent"
// EventDeleted is triggered when a message is permanently deleted.
EventDeleted EventType = "mail.deleted"
// EventTrashed is triggered when a message is moved to trash.
EventTrashed EventType = "mail.trashed"
// EventStarred is triggered when a message is starred or unstarred.
EventStarred EventType = "mail.starred"
// EventLabeled is triggered when a label is applied or removed.
EventLabeled EventType = "mail.labeled"
// EventFolderMoved is triggered when a message is moved to a different folder.
EventFolderMoved EventType = "mail.folder_moved"
)
// AllEventTypes lists all supported event types.
var AllEventTypes = []EventType{
EventReceived, EventSent, EventDeleted, EventTrashed,
EventStarred, EventLabeled, EventFolderMoved,
}
// Webhook represents a webhook subscription.
type Webhook struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Events []string `json:"events"`
Secret string `json:"secret,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Active bool `json:"active"`
CreatedAt string `json:"created_at"`
LastTriggeredAt string `json:"last_triggered_at,omitempty"`
LastStatus int `json:"last_status,omitempty"`
RetryCount int `json:"retry_count"`
MaxRetries int `json:"max_retries"`
TimeoutSec int `json:"timeout_sec"`
}
// WebhookEvent represents a webhook payload sent to the target URL.
type WebhookEvent struct {
ID string `json:"id"`
Type string `json:"type"`
Timestamp string `json:"timestamp"`
Account string `json:"account,omitempty"`
Data map[string]interface{} `json:"data"`
}
// WebhookStore manages webhook subscriptions.
type WebhookStore struct {
configDir string
webhooksFile string
mu sync.RWMutex
}
// NewWebhookStore creates a new webhook store.
func NewWebhookStore() (*WebhookStore, error) {
cfg := config.NewConfigManager()
configDir := cfg.ConfigDir()
return &WebhookStore{
configDir: configDir,
webhooksFile: filepath.Join(configDir, "webhooks.json"),
}, nil
}
// ListWebhooks returns all webhook subscriptions.
func (ws *WebhookStore) ListWebhooks() ([]Webhook, error) {
ws.mu.RLock()
defer ws.mu.RUnlock()
data, err := os.ReadFile(ws.webhooksFile)
if err != nil {
if os.IsNotExist(err) {
return []Webhook{}, nil
}
return nil, fmt.Errorf("failed to read webhooks file: %w", err)
}
var webhooks []Webhook
if err := json.Unmarshal(data, &webhooks); err != nil {
return nil, fmt.Errorf("failed to parse webhooks: %w", err)
}
return webhooks, nil
}
// GetWebhook retrieves a webhook by ID.
func (ws *WebhookStore) GetWebhook(id string) (*Webhook, error) {
webhooks, err := ws.ListWebhooks()
if err != nil {
return nil, err
}
for _, wh := range webhooks {
if wh.ID == id {
return &wh, nil
}
}
return nil, fmt.Errorf("webhook %q not found", id)
}
// AddWebhook creates a new webhook subscription.
func (ws *WebhookStore) AddWebhook(name, url string, events []EventType, secret string) (*Webhook, error) {
ws.mu.Lock()
defer ws.mu.Unlock()
webhooks, err := ws.loadWebhooks()
if err != nil {
return nil, err
}
if url == "" {
return nil, fmt.Errorf("webhook URL is required")
}
if len(events) == 0 {
return nil, fmt.Errorf("at least one event type is required")
}
for _, event := range events {
found := false
for _, valid := range AllEventTypes {
if event == valid {
found = true
break
}
}
if !found {
return nil, fmt.Errorf("unknown event type: %s (valid: %v)", event, AllEventTypes)
}
}
if secret == "" {
secret = generateSecret()
}
wh := Webhook{
ID: generateID(),
Name: name,
URL: url,
Events: eventTypeStrings(events),
Secret: secret,
Headers: make(map[string]string),
Active: true,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
MaxRetries: 3,
TimeoutSec: 30,
}
webhooks = append(webhooks, wh)
if err := ws.saveWebhooks(webhooks); err != nil {
return nil, err
}
return &wh, nil
}
// UpdateWebhook updates an existing webhook subscription.
func (ws *WebhookStore) UpdateWebhook(id string, url, name *string, active *bool, events *[]string) (*Webhook, error) {
ws.mu.Lock()
defer ws.mu.Unlock()
webhooks, err := ws.loadWebhooks()
if err != nil {
return nil, err
}
for i, wh := range webhooks {
if wh.ID == id {
if url != nil {
webhooks[i].URL = *url
}
if name != nil {
webhooks[i].Name = *name
}
if active != nil {
webhooks[i].Active = *active
}
if events != nil {
webhooks[i].Events = *events
}
if err := ws.saveWebhooks(webhooks); err != nil {
return nil, err
}
return &webhooks[i], nil
}
}
return nil, fmt.Errorf("webhook %q not found", id)
}
// RemoveWebhook deletes a webhook subscription.
func (ws *WebhookStore) RemoveWebhook(id string) error {
ws.mu.Lock()
defer ws.mu.Unlock()
webhooks, err := ws.loadWebhooks()
if err != nil {
return err
}
newWebhooks := make([]Webhook, 0, len(webhooks))
found := false
for _, wh := range webhooks {
if wh.ID == id {
found = true
continue
}
newWebhooks = append(newWebhooks, wh)
}
if !found {
return fmt.Errorf("webhook %q not found", id)
}
return ws.saveWebhooks(newWebhooks)
}
// TriggerWebhook sends a webhook event to the configured URL.
func (ws *WebhookStore) TriggerWebhook(wh *Webhook, eventType EventType, data map[string]interface{}) error {
if !wh.Active {
return nil
}
event := WebhookEvent{
ID: generateID(),
Type: string(eventType),
Timestamp: time.Now().UTC().Format(time.RFC3339),
Data: data,
}
payload, err := json.Marshal(event)
if err != nil {
return fmt.Errorf("failed to marshal webhook payload: %w", err)
}
req, err := http.NewRequest("POST", wh.URL, bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("failed to create webhook request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Webhook-ID", wh.ID)
req.Header.Set("X-Webhook-Signature", ComputeSignature(wh.Secret, payload))
req.Header.Set("X-Webhook-Timestamp", event.Timestamp)
for k, v := range wh.Headers {
req.Header.Set(k, v)
}
client := &http.Client{Timeout: time.Duration(wh.TimeoutSec) * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to deliver webhook: %w", err)
}
defer resp.Body.Close()
return nil
}
// VerifySignature verifies a webhook payload signature.
func VerifySignature(secret string, payload []byte, signature string) bool {
expected := ComputeSignature(secret, payload)
return hmac.Equal([]byte(signature), []byte(expected))
}
// GetActiveWebhooksForEvent returns all active webhooks that listen to a given event.
func (ws *WebhookStore) GetActiveWebhooksForEvent(eventType EventType) ([]Webhook, error) {
webhooks, err := ws.ListWebhooks()
if err != nil {
return nil, err
}
var active []Webhook
for _, wh := range webhooks {
if !wh.Active {
continue
}
for _, e := range wh.Events {
if e == string(eventType) {
active = append(active, wh)
break
}
}
}
return active, nil
}
func (ws *WebhookStore) loadWebhooks() ([]Webhook, error) {
data, err := os.ReadFile(ws.webhooksFile)
if err != nil {
if os.IsNotExist(err) {
return []Webhook{}, nil
}
return nil, fmt.Errorf("failed to read webhooks: %w", err)
}
var webhooks []Webhook
if err := json.Unmarshal(data, &webhooks); err != nil {
return nil, fmt.Errorf("failed to parse webhooks: %w", err)
}
return webhooks, nil
}
func (ws *WebhookStore) saveWebhooks(webhooks []Webhook) error {
if err := os.MkdirAll(ws.configDir, 0700); err != nil {
return fmt.Errorf("failed to create config dir: %w", err)
}
data, err := json.MarshalIndent(webhooks, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal webhooks: %w", err)
}
return os.WriteFile(ws.webhooksFile, data, 0600)
}
// ComputeSignature computes the HMAC-SHA256 signature for a webhook payload.
func ComputeSignature(secret string, payload []byte) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
return hex.EncodeToString(mac.Sum(nil))
}
func generateID() string {
return fmt.Sprintf("wh_%d", time.Now().UnixNano())
}
func generateSecret() string {
b := make([]byte, 32)
if _, err := randRead(b); err != nil {
return fmt.Sprintf("secret-%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
func randRead(b []byte) (int, error) {
return rand.Read(b)
}
func eventTypeStrings(events []EventType) []string {
strs := make([]string, len(events))
for i, e := range events {
strs[i] = string(e)
}
return strs
}

84
man/pop.1 Normal file
View File

@@ -0,0 +1,84 @@
.TH POP 1 "April 2026" "pop 1.0" "ProtonMail CLI"
.SH NAME
pop \- A ProtonMail CLI tool written in Go
.SH SYNOPSIS
.B pop
.RI [ command ] [ options ]
.SH DESCRIPTION
.B pop
is a command-line interface for interacting with the ProtonMail API. It provides authentication, session management, and secure PGP encryption support.
.SH COMMANDS
.TP
.B login
Authenticate with ProtonMail API using interactive prompts with masked password input. Supports two-factor authentication (2FA).
.TP
.B logout
Clear stored session credentials and end current session.
.TP
.B session
Display current session information including user ID and authentication status.
.SH OPTIONS
.TP
.BR -h ", " \-\-help
Show help message for the command.
.TP
.BR -v ", " \-\-version
Show version information.
.SH CONFIGURATION
Configuration file: ~/.config/pop/config.json
Default configuration:
.RS
.nf
{
"api_base_url": "https://api.protonmail.ch",
"timeout_sec": 30,
"rate_limit_requests": 100,
"rate_limit_window_sec": 60
}
.fi
.RE
Session file: ~/.config/pop/session.json
.SH FILES
.TP
.B ~/.config/pop/config.json
Application configuration
.TP
.B ~/.config/pop/session.json
Session credentials and tokens
.TP
.B ~/.config/pop/attachments/
Attachment storage directory
.SH EXAMPLES
.TP
.B pop login
Start interactive authentication with ProtonMail
.TP
.B pop session
Display current session information
.TP
.B pop logout
End current session and clear credentials
.SH EXIT STATUS
.TP
.B 0
Successful execution
.TP
.B 1
General error
.TP
.B 2
Authentication error
.SH DIAGNOSTICS
Errors are reported to stderr with descriptive messages. Common issues include:
- Network connectivity problems
- Invalid credentials
- Session expiration
- Configuration file parse errors
.SH BUGS
Report bugs at https://github.com/frenocorp/pop/issues
.SH AUTHOR
FrenoCorp Development Team
.SH COPYRIGHT
Copyright (c) 2026 FrenoCorp. MIT License.

28
tests/README.md Normal file
View 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
View 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
View 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")
}