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>
376 lines
9.1 KiB
Go
376 lines
9.1 KiB
Go
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
|
|
}
|