// Package outbound implements HTTP delivery independently of ingress protocols. package outbound import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "time" "github.com/example/notify-gateway/internal/config" ) type Response struct { StatusCode int Body string Retryable bool RetryAfter time.Duration } func Send(ctx context.Context, destination config.OutboundConfig, payload map[string]any) (Response, error) { return SendWithID(ctx, destination, payload, "") } func SendWithID(ctx context.Context, destination config.OutboundConfig, payload map[string]any, id string) (Response, error) { if err := config.ValidateOutbound(destination); err != nil { return Response{}, err } b, err := json.Marshal(payload) if err != nil { return Response{}, fmt.Errorf("encode outbound payload: %w", err) } if !destination.Live { preview, _ := json.Marshal(map[string]any{"dry_run": true, "provider": destination.Provider, "body": payload}) return Response{StatusCode: http.StatusOK, Body: string(preview)}, nil } if destination.Provider == "smtp" { return sendSMTP(ctx, destination, payload, id) } token, err := config.Secret(destination.BearerToken) if err != nil { return Response{}, err } if destination.Provider == "ntfy" { p := map[string]any{"topic": destination.Topic, "title": payload["title"], "message": payload["message"]} priority := 0 switch v := payload["priority"].(type) { case int: priority = v case float64: priority = int(v) } if priority > 0 { p["priority"] = min(priority, 5) } b, err = json.Marshal(p) if err != nil { return Response{}, err } } u, _ := url.Parse(destination.URL) if destination.Provider == "discord" { q := u.Query() q.Set("wait", "true") u.RawQuery = q.Encode() } req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(b)) if err != nil { return Response{}, fmt.Errorf("outbound request could not be created") } req.Header.Set("Content-Type", "application/json") if token != "" { if destination.Provider == "gotify" { req.Header.Set("X-Gotify-Key", token) } else { req.Header.Set("Authorization", "Bearer "+token) } } if id != "" { req.Header.Set("Idempotency-Key", id) req.Header.Set("X-Notify-Gateway-Delivery", id) } timeout := destination.TimeoutS if timeout == 0 { timeout = 15 } client := &http.Client{ Timeout: time.Duration(timeout) * time.Second, // Never forward webhook credentials to a redirect destination. CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, } resp, err := client.Do(req) if err != nil { // url.Error embeds the URL, including Discord's secret token. if ctx.Err() != nil { return Response{Retryable: true}, ctx.Err() } return Response{Retryable: true}, fmt.Errorf("outbound HTTP request failed (connection or timeout)") } defer resp.Body.Close() _, readErr := io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)) out := Response{StatusCode: resp.StatusCode, Retryable: RetryableHTTP(resp.StatusCode), RetryAfter: ParseRetryAfter(resp.Header.Get("Retry-After"), time.Now())} // Remote bodies may echo credentials or content. Only expose the status. if resp.StatusCode < 200 || resp.StatusCode >= 300 { return out, fmt.Errorf("outbound HTTP %d", resp.StatusCode) } if readErr != nil { out.Retryable = true return out, fmt.Errorf("outbound response could not be read") } return out, nil } func RetryableHTTP(code int) bool { return code == 0 || code == 408 || code == 425 || code == 429 || code >= 500 } func ParseRetryAfter(value string, now time.Time) time.Duration { if n, err := strconv.ParseFloat(value, 64); err == nil && n > 0 && n <= 86400 { return time.Duration(n * float64(time.Second)) } if t, err := http.ParseTime(value); err == nil && t.After(now) { return min(t.Sub(now), 24*time.Hour) } return 0 }