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:
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