// 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, ¬ify.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-3/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, } }