Files
pop/internal/webhook/webhook.go
Michael Freno bf26cd3ed6 feat: implement Milestone 3 integration points
Add comprehensive integration capabilities to Pop CLI:

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

All compilation errors fixed and build verified CLEAN.

Security review delegated to FRE-5202.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-14 00:40:24 -04:00

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
}