185 lines
4.7 KiB
Go
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, ¬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,
|
|
}
|
|
}
|