package webhooks import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "strings" "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/ultisuite/ulti-backend/internal/observability" ) type Executor struct { db *pgxpool.Pool client *http.Client logger *slog.Logger } func NewExecutor(db *pgxpool.Pool) *Executor { return &Executor{ db: db, client: &http.Client{ Timeout: 10 * time.Second, }, logger: slog.Default().With("component", "webhooks"), } } type MessageContext struct { SenderName string `json:"sender_name"` SenderEmail string `json:"sender_email"` Subject string `json:"subject"` BodyText string `json:"body_text"` BodyHTML string `json:"body_html"` Date string `json:"date"` Recipients string `json:"recipients"` HasAttachment bool `json:"has_attachment"` MessageID string `json:"message_id"` } func (e *Executor) Execute(ctx context.Context, templateID string, msgCtx *MessageContext) error { var ( url string method string headersJSON []byte bodyTemplate string ) err := e.db.QueryRow(ctx, ` SELECT url, method, headers, body_template FROM webhook_templates WHERE id = $1 AND is_active = true `, templateID).Scan(&url, &method, &headersJSON, &bodyTemplate) if err != nil { return fmt.Errorf("query template: %w", err) } body := interpolate(bodyTemplate, msgCtx) start := time.Now() req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewBufferString(body)) if err != nil { return fmt.Errorf("create request: %w", err) } var headers map[string]string json.Unmarshal(headersJSON, &headers) for k, v := range headers { req.Header.Set(k, v) } if req.Header.Get("Content-Type") == "" { req.Header.Set("Content-Type", "application/json") } resp, err := e.client.Do(req) requestDuration := time.Since(start) durationMS := requestDuration.Milliseconds() var statusCode int var responseBody string var execError string if err != nil { execError = err.Error() } else { statusCode = resp.StatusCode respBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) resp.Body.Close() responseBody = string(respBytes) } _, logErr := e.db.Exec(ctx, ` INSERT INTO webhook_logs (template_id, message_id, status_code, response_body, error, duration_ms) VALUES ($1, $2, $3, $4, $5, $6) `, templateID, msgCtx.MessageID, statusCode, responseBody, execError, durationMS) if logErr != nil { e.logger.Error("failed to log webhook", "error", logErr) } if err != nil { observability.ObserveWebhookExecution("error", statusCode, requestDuration) return fmt.Errorf("request failed: %w", err) } if statusCode >= 400 { observability.ObserveWebhookExecution("error", statusCode, requestDuration) return fmt.Errorf("webhook returned %d", statusCode) } observability.ObserveWebhookExecution("success", statusCode, requestDuration) return nil } func interpolate(template string, ctx *MessageContext) string { r := strings.NewReplacer( "$sender.name", ctx.SenderName, "$sender.email", ctx.SenderEmail, "$subject", ctx.Subject, "$body.textContent", ctx.BodyText, "$body.htmlContent", ctx.BodyHTML, "$date", ctx.Date, "$recipients.to", ctx.Recipients, "$message_id", ctx.MessageID, ) return r.Replace(template) }