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 }