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