persona-community-5/pkg/notify/client.go
jordan bd2f591b98
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/manual/woodpecker Pipeline was successful
Initialize project from skeleton template
2026-02-24 07:39:46 +00:00

185 lines
4.7 KiB
Go

// Package notify provides a Go client for the orchard9 notify service.
//
// The notify service handles email delivery with provider routing, retries,
// delivery tracking, and suppression handling.
//
// Basic usage:
//
// client, err := notify.NewClient(notify.Config{
// URL: os.Getenv("NOTIFY_URL"),
// APIKey: os.Getenv("NOTIFY_API_KEY"),
// })
// if err != nil {
// log.Fatal(err)
// }
//
// resp, err := client.SendEmail(ctx, &notify.SendRequest{
// To: "user@example.com",
// From: "noreply@myapp.threesix.ai",
// Content: notify.Content{Subject: "Hello", Text: "World"},
// Meta: notify.Meta{Host: "myapp.threesix.ai"},
// })
package notify
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"time"
"git.threesix.ai/jordan/persona-community-5/pkg/httpclient"
)
const (
defaultTimeout = 30 * time.Second
defaultMaxRetries = 3
)
// Config holds configuration for the notify client.
type Config struct {
URL string // Required: base URL (e.g. "https://notify.threesix.ai")
APIKey string // Required: send API key (notify_send_xxx)
Timeout time.Duration // Optional: defaults to 30s
MaxRetries int // Optional: defaults to 3
Logger *slog.Logger // Optional: defaults to slog.Default()
}
// Client is the notify API client.
type Client struct {
httpClient *httpclient.Client
baseURL string
apiKey string
logger *slog.Logger
}
// NewClient creates a new notify API client.
func NewClient(config Config) (*Client, error) {
if config.URL == "" {
return nil, fmt.Errorf("%w: URL is required", ErrInvalidConfig)
}
if config.APIKey == "" {
return nil, fmt.Errorf("%w: API key is required", ErrInvalidConfig)
}
if _, err := url.Parse(config.URL); err != nil {
return nil, fmt.Errorf("%w: invalid URL: %v", ErrInvalidConfig, err)
}
if config.Timeout == 0 {
config.Timeout = defaultTimeout
}
if config.MaxRetries == 0 {
config.MaxRetries = defaultMaxRetries
}
if config.Logger == nil {
config.Logger = slog.Default()
}
return &Client{
httpClient: httpclient.New(httpclient.Config{
Timeout: config.Timeout,
MaxRetries: config.MaxRetries,
Logger: config.Logger,
}),
baseURL: config.URL,
apiKey: config.APIKey,
logger: config.Logger,
}, nil
}
// SendEmail sends an email through the notify service.
// Returns the message ID and status on success (202 Accepted).
func (c *Client) SendEmail(ctx context.Context, req *SendRequest) (*SendResponse, error) {
respBody, err := c.doRequest(ctx, http.MethodPost, "/email", req)
if err != nil {
return nil, err
}
var resp SendResponse
if err := json.Unmarshal(respBody, &resp); err != nil {
return nil, fmt.Errorf("unmarshal response: %w", err)
}
return &resp, nil
}
// GetMessage retrieves the full status of a sent message.
func (c *Client) GetMessage(ctx context.Context, id string) (*Message, error) {
respBody, err := c.doRequest(ctx, http.MethodGet, "/messages/"+id, nil)
if err != nil {
return nil, err
}
var msg Message
if err := json.Unmarshal(respBody, &msg); err != nil {
return nil, fmt.Errorf("unmarshal response: %w", err)
}
return &msg, nil
}
// doRequest is a helper for making HTTP requests to the notify API.
func (c *Client) doRequest(ctx context.Context, method, path string, bodyData interface{}) ([]byte, error) {
var reqBody io.Reader
if bodyData != nil {
jsonBody, err := json.Marshal(bodyData)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
reqBody = bytes.NewReader(jsonBody)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
if bodyData != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response body: %w", err)
}
// 2xx = success
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return respBody, nil
}
// Parse error response
var errResp ErrorResponse
baseErr := classifyError(resp.StatusCode, "")
if err := json.Unmarshal(respBody, &errResp); err != nil {
return nil, &APIError{
StatusCode: resp.StatusCode,
Message: string(respBody),
err: baseErr,
}
}
// Re-classify with the error code from the response.
if errResp.Code != "" {
baseErr = classifyError(resp.StatusCode, errResp.Code)
}
return nil, &APIError{
StatusCode: resp.StatusCode,
Message: errResp.Error,
Code: errResp.Code,
err: baseErr,
}
}