Compare commits
5 Commits
c1fc21702c
...
6cc520e221
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cc520e221 | |||
|
|
0684e726bb | ||
|
|
e499d16b7c | ||
|
|
af25fd5575 | ||
| 35d47733ea |
175
FRE-683-SECURITY-FIXES.md
Normal file
175
FRE-683-SECURITY-FIXES.md
Normal 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: ¬es,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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 = ¬es
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 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
|
||||||
@@ -4,7 +4,7 @@ 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
|
||||||
@@ -24,12 +24,9 @@ make install
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Initialize login (interactive mode)
|
# Initialize login (interactive mode with masked password prompt)
|
||||||
pop login
|
pop login
|
||||||
|
|
||||||
# Login with explicit credentials
|
|
||||||
pop login --email user@proton.me --password secret
|
|
||||||
|
|
||||||
# Check current session
|
# Check current session
|
||||||
pop session
|
pop session
|
||||||
|
|
||||||
|
|||||||
29
cmd/auth.go
29
cmd/auth.go
@@ -5,6 +5,7 @@ import (
|
|||||||
"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,15 +15,21 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
manager, err := auth.NewSessionManager()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create session manager: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return manager.LoginInteractive(cfg.APIBaseURL)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringP("email", "e", "", "ProtonMail email address")
|
|
||||||
cmd.Flags().StringP("password", "p", "", "ProtonMail password")
|
|
||||||
cmd.Flags().BoolP("interactive", "i", true, "Interactive prompt for credentials")
|
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +39,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 +56,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)
|
||||||
|
|||||||
@@ -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: ¬es,
|
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 = ¬es
|
||||||
}
|
}
|
||||||
|
|
||||||
updated, err := manager.Update(id, req)
|
updated, err := manager.Update(id, req)
|
||||||
|
|||||||
52
cmd/draft.go
52
cmd/draft.go
@@ -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, err := checkAuthenticated()
|
||||||
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)
|
||||||
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, err := checkAuthenticated()
|
||||||
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)
|
||||||
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, err := checkAuthenticated()
|
||||||
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)
|
||||||
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, err := checkAuthenticated()
|
||||||
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)
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
411
cmd/folders.go
Normal file
411
cmd/folders.go
Normal 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)
|
||||||
|
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()
|
||||||
|
}
|
||||||
212
cmd/mail.go
212
cmd/mail.go
@@ -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 checkAuthenticated() (*auth.Session, error) {
|
||||||
|
sessionMgr, err := auth.NewSessionManager()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create session manager: %w", err)
|
||||||
|
}
|
||||||
|
authenticated, err := sessionMgr.IsAuthenticated()
|
||||||
|
if err != nil || !authenticated {
|
||||||
|
return nil, fmt.Errorf("not authenticated (run 'pop login' first): %w", err)
|
||||||
|
}
|
||||||
|
session, err := sessionMgr.GetSession()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("not authenticated: %w", err)
|
||||||
|
}
|
||||||
|
return session, 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, err := checkAuthenticated()
|
||||||
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)
|
||||||
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, err := checkAuthenticated()
|
||||||
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)
|
||||||
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 != "" {
|
||||||
data, err := os.ReadFile(bodyFile)
|
bodyContent = body
|
||||||
if err != nil {
|
} else if bodyFile != "" {
|
||||||
return fmt.Errorf("failed to read body file: %w", err)
|
data, err := os.ReadFile(bodyFile)
|
||||||
}
|
if err != nil {
|
||||||
body = string(data)
|
return fmt.Errorf("failed to read body file: %w", err)
|
||||||
}
|
}
|
||||||
|
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,25 +221,24 @@ 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, err := checkAuthenticated()
|
||||||
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)
|
||||||
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 {
|
||||||
return fmt.Errorf("failed to send message: %w", err)
|
return fmt.Errorf("failed to send message: %w", err)
|
||||||
@@ -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, err := checkAuthenticated()
|
||||||
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)
|
||||||
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, err := checkAuthenticated()
|
||||||
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)
|
||||||
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, err := checkAuthenticated()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := api.NewProtonMailClient(cfg)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ 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())
|
||||||
|
|
||||||
return rootCmd
|
return rootCmd
|
||||||
}
|
}
|
||||||
|
|||||||
10
go.mod
10
go.mod
@@ -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
31
go.sum
@@ -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=
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,315 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"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 {
|
||||||
UID string `json:"uid"`
|
UID string `json:"uid"`
|
||||||
AccessToken string `json:"access_token"`
|
AccessToken string `json:"access_token"`
|
||||||
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(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.NewRequest("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(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.NewRequest("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.NewRequest("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 +321,212 @@ 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 {
|
||||||
|
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.NewRequest("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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
367
internal/labels/client.go
Normal 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
58
internal/labels/types.go
Normal 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"`
|
||||||
|
}
|
||||||
@@ -12,8 +12,9 @@ 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 +24,44 @@ 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/api/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")
|
||||||
|
|
||||||
resp, err := c.apiClient.Do(httpReq)
|
resp, err := c.apiClient.Do(httpReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -57,13 +69,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,14 +83,21 @@ 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{}
|
body := map[string]string{
|
||||||
params.Set("Passphrase", passphrase)
|
"Passphrase": passphrase,
|
||||||
|
}
|
||||||
|
|
||||||
reqURL := fmt.Sprintf("%s/api/messages/%s?%s", c.baseURL, url.QueryEscape(messageID), 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/api/messages/%s", c.baseURL, url.QueryEscape(messageID))
|
||||||
|
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
resp, err := c.apiClient.Do(httpReq)
|
resp, err := c.apiClient.Do(httpReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -86,7 +105,7 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -94,7 +113,7 @@ func (c *Client) GetMessage(messageID string, passphrase string) (*Message, erro
|
|||||||
var result struct {
|
var result struct {
|
||||||
Data Message `json:"Data"`
|
Data Message `json:"Data"`
|
||||||
}
|
}
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,34 +121,45 @@ func (c *Client) GetMessage(messageID string, passphrase string) (*Message, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
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/api/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,8 +175,9 @@ 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{}
|
formData := url.Values{}
|
||||||
|
formData.Set("Passphrase", passphrase)
|
||||||
reqURL := fmt.Sprintf("%s/api/messages/%s/movetotrash", c.baseURL, url.QueryEscape(messageID))
|
reqURL := fmt.Sprintf("%s/api/messages/%s/movetotrash", c.baseURL, url.QueryEscape(messageID))
|
||||||
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBufferString(formData.Encode()))
|
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBufferString(formData.Encode()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -190,32 +221,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/api/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,7 +255,7 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -233,7 +265,7 @@ func (c *Client) SaveDraft(draft Draft, passphrase string) (string, error) {
|
|||||||
MessageID string `json:"MessageID"`
|
MessageID string `json:"MessageID"`
|
||||||
} `json:"Data"`
|
} `json:"Data"`
|
||||||
}
|
}
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,26 +273,28 @@ func (c *Client) SaveDraft(draft Draft, passphrase string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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)
|
"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 {
|
|
||||||
ccJSON, _ := json.Marshal(draft.CC)
|
|
||||||
formData.Set("CC", string(ccJSON))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
formData.Set("Body", draft.Body)
|
if len(draft.CC) > 0 {
|
||||||
|
body["CC"] = draft.CC
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBody, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
reqURL := fmt.Sprintf("%s/api/messages/%s", c.baseURL, url.QueryEscape(messageID))
|
reqURL := fmt.Sprintf("%s/api/messages/%s", c.baseURL, url.QueryEscape(messageID))
|
||||||
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 {
|
||||||
@@ -276,8 +310,9 @@ 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{}
|
formData := url.Values{}
|
||||||
|
formData.Set("Passphrase", passphrase)
|
||||||
reqURL := fmt.Sprintf("%s/api/messages/%s/send", c.baseURL, url.QueryEscape(messageID))
|
reqURL := fmt.Sprintf("%s/api/messages/%s/send", c.baseURL, url.QueryEscape(messageID))
|
||||||
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBufferString(formData.Encode()))
|
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBufferString(formData.Encode()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -310,17 +345,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/api/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,13 +370,13 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
PrivateKey *crypto.Key
|
mu sync.Mutex
|
||||||
PublicKey []byte
|
PrivateKey *crypto.Key
|
||||||
|
PublicKey []byte
|
||||||
|
PrivateKeyData []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
type PGPService struct {
|
type PGPService struct {
|
||||||
@@ -29,26 +32,136 @@ func NewPGPService(privateKeyArmored string) (*PGPService, error) {
|
|||||||
|
|
||||||
return &PGPService{
|
return &PGPService{
|
||||||
keyRing: &PGPKeyRing{
|
keyRing: &PGPKeyRing{
|
||||||
PrivateKey: privateKey,
|
PrivateKey: privateKey,
|
||||||
PublicKey: publicKey,
|
PublicKey: publicKey,
|
||||||
|
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.NewKeyFromArmored(string(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 != "" {
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
@@ -65,7 +178,7 @@ func (s *PGPService) GenerateKeyPair(email string, passphrase string) (privateKe
|
|||||||
|
|
||||||
pubArmor := string(pubKeyBytes)
|
pubArmor := string(pubKeyBytes)
|
||||||
|
|
||||||
return string(privateArmor), pubArmor, nil
|
return privateArmor, pubArmor, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PGPService) GetFingerprint() (string, error) {
|
func (s *PGPService) GetFingerprint() (string, error) {
|
||||||
@@ -76,8 +189,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 +227,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 +260,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
84
man/pop.1
Normal file
84
man/pop.1
Normal 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.
|
||||||
Reference in New Issue
Block a user