Files
warmbly/cmd/warmblyctl/api.go
T
Matthew Meszaros e143cb0628 feat: give warmblyctl an API-key half so agents and scripts can operate any Warmbly instance, ship in-repo agent skills that teach it, and cut the README quick start and self-hosting sections down to commands plus docs links (#135)
* feat: cut the README quick start and self-hosting sections down to the install command, one paragraph of what it does, and links out to the local development, first-run, deployment and warmblyctl docs pages, dropping the recovery if/then table, the MAIL_TRANSPORT invitation note and the dependency matrix that all duplicate those pages

* feat: give warmblyctl an API-key half so agents and scripts can operate any Warmbly instance including the hosted one, adding a raw passthrough (warmblyctl api get/post/patch/put/delete <path> with --data taking a literal, - or @file, and --idempotency-key) plus eleven typed families (me, campaign, contact, mailbox, inbox, analytics, settings, webhook, apikey, template, crm) driven by one spec table that generates dispatch, flags, per-command help and the request, covering list/get/create/update/delete, sequence steps, sender pools, preflight/start/stop/test-email, contact notes/timeline/import/export, mailbox behavior/verify/send and the six warmup controls, unibox threads/reply/compose/seen/agent-drafts/scheduled sends, outreach settings, webhook secrets and deliveries, and API key self-service, authenticated with Bearer wmbly_ keys from WARMBLY_API_KEY against WARMBLY_API_URL falling back to API_PUBLIC_URL then the hosted service, printing the API's JSON untouched and surfacing the error envelope's code and request_id with Retry-After on 429, while the DB-direct operator commands and their trust model stay exactly as they were

* feat: ship two in-repo agent skills so AI assistants working against Warmbly discover the right warmblyctl half on their own, .claude/skills/warmbly-api teaching product operation over the API commands (key and URL setup including the seeded local dev key, the eleven command families, pagination and error-code and Idempotency-Key conventions, and a sending-safety section that names the six commands that put real mail on the wire and holds agents to preflight before start and the 50/day default cap) and .claude/skills/warmbly-ops teaching instance administration over the DB-direct commands (status --json as the contract to parse, the recovery command table, TTY versus -T piping, Redis-down behaviour, and org export/import handling including --dry-run first and the sensitivity of credential archives), each pointing at the other for what it does not cover, narrowing the .gitignore .claude/ rule to .claude/* with !.claude/skills/ so personal agent state stays local while the skills ship

* feat: document warmblyctl's new API half on the warmblyctl reference page, reframing the intro around the two halves and their two trust models and replacing the 'no HTTP surface and never will' line with the accurate claim that the CLI never serves HTTP while the API commands are a client of the already-gated public API, adding WARMBLY_API_KEY and WARMBLY_API_URL to the environment table with the API_PUBLIC_URL-then-hosted fallback, renaming The commands to The operator commands, and adding an API commands section covering key setup, the eleven command families, the raw /v1 passthrough with curl-style --data forms, the pagination, idempotency-key and Retry-After conventions, a warning callout naming the six commands that put real mail on the wire with preflight-before-start guidance, and a pointer to the shipped .claude/skills agent skills, plus API authentication and permissions links in See also

* feat: move the shipped agent skills from .claude/skills/ to a top-level skills/ directory so they follow the convention other repos use for distributable agent skills rather than living inside Claude Code's personal state directory, restoring the .gitignore .claude/ rule to its original form since nothing tracked lives under it anymore, and updating the warmblyctl reference's For AI agents section to name skills/ and show installing a skill by copying it into the agent's own skills directory or pointing the agent at SKILL.md directly

* feat: clear the Security Scan failure by lifting the two flagged indirect Go modules past their fixed versions, github.com/moby/go-archive from v0.2.0 to v0.3.0 for the CVE-2026-17106 tar path traversal and golang.org/x/mod from v0.37.0 to v0.40.0 for the CVE-2026-56864 and CVE-2026-56865 GOSUMDB and GOPROXY forgery pair, with the x/sys, x/text and x/tools bumps go mod tidy pulls along

* feat: take the Trivy dependency scan off the PR gate and restructure CI the way larger projects do, because a full-repo CVE scan on every pull request goes red the morning any dependency gets a new advisory regardless of what the PR touches, which is exactly how this branch failed on two indirect Go modules it never went near, moving the scan to its own security.yml running weekly, on demand, and on main pushes that change a dependency manifest, pinned to trivy-action 0.36.0 instead of @master, extracting the pnpm+Node+frozen-install boilerplate repeated across the web, admin and site jobs into a .github/actions/setup-pnpm composite action with the store cached per lockfile, and collapsing the CI Status rollup's ten hand-enumerated result checks that had to be edited in two places per new job into a single contains(needs.*.result, ...) expression over failure and cancelled, all validated with actionlint
2026-08-19 20:59:40 -07:00

284 lines
8.6 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/warmbly/warmbly/internal/app/apikey"
)
// apiClient talks to a Warmbly instance's public REST API with an API key.
// It is how the CLI drives a running instance, including a hosted one, where
// the database commands cannot reach. The DB commands stay for recovery; this
// path is for day-to-day operation, which makes it the surface agents script.
type apiClient struct {
base string
key string
http *http.Client
debug bool
}
// apiEndpoint resolves the API base URL an agent most plausibly means:
// an explicit WARMBLY_API_URL, then the instance's own API_PUBLIC_URL when the
// command runs where the backend's environment is present, then the hosted
// service.
func apiEndpoint() string {
if v := strings.TrimSpace(os.Getenv("WARMBLY_API_URL")); v != "" {
return strings.TrimRight(v, "/")
}
if v := strings.TrimSpace(os.Getenv("API_PUBLIC_URL")); v != "" {
return strings.TrimRight(v, "/")
}
return "https://api.warmbly.com"
}
func newAPIClient() (*apiClient, error) {
key := strings.TrimSpace(os.Getenv("WARMBLY_API_KEY"))
if key == "" {
return nil, errors.New("WARMBLY_API_KEY is not set, so there is no key to call the API with.\nCreate one under Settings > API keys (or `warmblyctl api keys` on an instance you can already reach), then:\n export WARMBLY_API_KEY=wmbly_...\n export WARMBLY_API_URL=https://api.your-instance.com # omit for the hosted service")
}
if !strings.HasPrefix(key, apikey.KeyPrefix) {
return nil, fmt.Errorf("WARMBLY_API_KEY does not look like a Warmbly API key: it should start with %q.", apikey.KeyPrefix)
}
return &apiClient{
base: apiEndpoint(),
key: key,
http: &http.Client{Timeout: 60 * time.Second},
debug: os.Getenv("WARMBLYCTL_DEBUG") != "",
}, nil
}
// apiError is the backend's stable error envelope. Every field is part of the
// public contract, so they are safe to surface verbatim.
type apiError struct {
Error string `json:"error"`
Message string `json:"message"`
Code string `json:"code"`
RequestID string `json:"request_id"`
}
// do performs one request. path is relative to /v1 unless it already names a
// version. A non-2xx response comes back as an error carrying the backend's
// own code and request id, so an agent can branch without parsing prose.
func (c *apiClient) do(ctx context.Context, method, path string, query url.Values, body any, idempotencyKey string) (json.RawMessage, error) {
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
if !strings.HasPrefix(path, "/v1/") && path != "/v1" {
path = "/v1" + path
}
full := c.base + path
if len(query) > 0 {
full += "?" + query.Encode()
}
var reader io.Reader
if body != nil {
switch b := body.(type) {
case json.RawMessage:
reader = bytes.NewReader(b)
case []byte:
reader = bytes.NewReader(b)
default:
buf, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("encoding the request body: %w", err)
}
reader = bytes.NewReader(buf)
}
}
req, err := http.NewRequestWithContext(ctx, method, full, reader)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.key)
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
if c.debug {
fmt.Fprintf(os.Stderr, "> %s %s\n", method, full)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("could not reach the API at %s: %w\nSet WARMBLY_API_URL to your instance's API base URL, or omit it for the hosted service.", c.base, err)
}
defer resp.Body.Close()
payload, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
if err != nil {
return nil, fmt.Errorf("reading the API response: %w", err)
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return payload, nil
}
var apiErr apiError
if json.Unmarshal(payload, &apiErr) == nil && (apiErr.Message != "" || apiErr.Code != "") {
msg := apiErr.Message
if msg == "" {
msg = apiErr.Error
}
detail := fmt.Sprintf("%s %s failed (%d %s): %s", method, path, resp.StatusCode, apiErr.Code, msg)
if apiErr.RequestID != "" {
detail += " (request " + apiErr.RequestID + ")"
}
if resp.StatusCode == http.StatusTooManyRequests {
if retry := resp.Header.Get("Retry-After"); retry != "" {
detail += ". Rate limited; retry after " + retry + "s."
}
}
return nil, errors.New(detail)
}
return nil, fmt.Errorf("%s %s failed with HTTP %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(payload)))
}
// printJSON writes the API's response for a human and a parser alike: the
// payload is already JSON, so it is re-indented and passed through untouched.
func printJSON(payload json.RawMessage) error {
if len(bytes.TrimSpace(payload)) == 0 {
fmt.Println("{}")
return nil
}
var buf bytes.Buffer
if err := json.Indent(&buf, payload, "", " "); err != nil {
// Not JSON (some endpoints stream files); pass it through as-is.
_, werr := os.Stdout.Write(payload)
if werr == nil {
fmt.Println()
}
return werr
}
fmt.Println(buf.String())
return nil
}
// readBodyArg turns a --data value into a request body: a JSON literal, `-`
// for stdin, or @path for a file, the same conventions curl taught everyone.
func readBodyArg(data string) (json.RawMessage, error) {
data = strings.TrimSpace(data)
if data == "" {
return nil, nil
}
var raw []byte
switch {
case data == "-":
b, err := io.ReadAll(os.Stdin)
if err != nil {
return nil, fmt.Errorf("reading the body from stdin: %w", err)
}
raw = b
case strings.HasPrefix(data, "@"):
b, err := os.ReadFile(strings.TrimPrefix(data, "@"))
if err != nil {
return nil, fmt.Errorf("reading the body file: %w", err)
}
raw = b
default:
raw = []byte(data)
}
if !json.Valid(raw) {
return nil, errors.New("the request body is not valid JSON. Pass a JSON literal, `-` for stdin, or @file.")
}
return json.RawMessage(raw), nil
}
// runAPI is the raw passthrough: any method, any path, so nothing the API can
// do is out of the CLI's reach even before a typed command exists for it.
func runAPI(ctx context.Context, args []string) error {
if len(args) == 0 {
apiUsage(os.Stderr)
return errors.New("`api` needs a method and a path. Pick a form from the list above.")
}
method := strings.ToUpper(args[0])
switch method {
case "HELP", "-H", "--HELP":
apiUsage(os.Stdout)
return nil
case "GET", "POST", "PATCH", "PUT", "DELETE":
default:
apiUsage(os.Stderr)
return fmt.Errorf("unknown method %q. Use get, post, patch, put or delete.", args[0])
}
fs := newFlagSet("api")
data := fs.String("data", "", "JSON request body: a literal, `-` for stdin, or @file")
idem := fs.String("idempotency-key", "", "Idempotency-Key header for a safely retryable write")
if err := fs.Parse(args[1:]); err != nil {
return err
}
if fs.NArg() == 0 {
return errors.New("missing the request path, for example `warmblyctl api get /campaigns`.")
}
if fs.NArg() > 1 {
return fmt.Errorf("unexpected argument %q. Put query parameters in the path itself: /campaigns?limit=10", fs.Arg(1))
}
rawPath := fs.Arg(0)
parsed, err := url.Parse(rawPath)
if err != nil {
return fmt.Errorf("%q is not a usable path: %w", rawPath, err)
}
body, err := readBodyArg(*data)
if err != nil {
return err
}
if body != nil && method == "GET" {
return errors.New("a GET request carries no body. Put parameters in the query string instead.")
}
client, err := newAPIClient()
if err != nil {
return err
}
payload, err := client.do(ctx, method, parsed.Path, parsed.Query(), body, *idem)
if err != nil {
return err
}
return printJSON(payload)
}
func apiUsage(w *os.File) {
fmt.Fprint(w, `Call the public REST API of a Warmbly instance directly.
Usage:
warmblyctl api <get|post|patch|put|delete> <path> [--data JSON] [--idempotency-key KEY]
Paths are relative to /v1. Examples:
warmblyctl api get "/campaigns?limit=10"
warmblyctl api post /contacts --data '{"email":"jane@example.com"}'
warmblyctl api patch /campaigns/<id> --data @changes.json
warmblyctl api delete /webhooks/<id>
Environment:
WARMBLY_API_KEY The API key (starts with wmbly_). Required.
WARMBLY_API_URL API base URL. Defaults to the instance's own API_PUBLIC_URL
when run inside the backend, then https://api.warmbly.com.
The response body is printed to stdout as JSON. A non-2xx response exits 1 and
prints the API's machine-readable error code and request id to stderr.
The typed commands (campaign, contact, mailbox, inbox, ...) cover the common
operations with flags; this passthrough covers everything else.
`)
}