ultisuite-backend/internal/mail/webhooks/executor.go
R3D347HR4Y 2057ccd816 Add observability features with Prometheus and Grafana integration
- Introduced health checks for Nextcloud, Immich, and Jitsi in the .env.example file.
- Implemented Prometheus metrics for HTTP requests, IMAP sync, outbox processing, and webhook executions.
- Added Grafana configuration files for dashboards and data sources.
- Updated Docker Compose to include Prometheus and Grafana services.
- Enhanced logging middleware to include request IDs and metrics tracking.
- Created health checker for monitoring database and external service statuses.
- Updated README with observability setup instructions and service URLs.
2026-05-22 16:17:10 +02:00

131 lines
3.3 KiB
Go

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)
}