Files
warmbly/cmd/warmblyctl/api_resources.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

332 lines
18 KiB
Go

package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"net/url"
"os"
"strings"
)
// bodyMode says what a typed command does with --data.
type bodyMode int
const (
bodyNone bodyMode = iota // the endpoint takes no body
bodyOptional // --data may be given; an empty one is sent as {}
bodyRequired // --data must be given
)
// apiSpec is one typed command over the public API. The table below is the
// entire definition of the resource commands: dispatch, flags, help and the
// request all come from it, so adding an endpoint is adding a row.
type apiSpec struct {
name string // "campaign start"
summary string // one line for help output
method string // HTTP method
path string // /v1-relative path, may contain {id} and {child}
body bodyMode // what --data means here
child string // flag name filling {child}, e.g. "step"
query []string // query parameters exposed as flags
sends bool // true when the command can put real mail on the wire
}
var apiSpecs = []apiSpec{
{name: "me", summary: "Show who the API key is: user, organization, and granted scopes", method: "GET", path: "/me"},
// Campaigns.
{name: "campaign list", summary: "List campaigns", method: "GET", path: "/campaigns", query: []string{"limit", "cursor", "q", "status", "folder"}},
{name: "campaign get", summary: "Get one campaign", method: "GET", path: "/campaigns/{id}"},
{name: "campaign overview", summary: "Status and folder counts across all campaigns", method: "GET", path: "/campaigns-overview"},
{name: "campaign create", summary: "Create a campaign", method: "POST", path: "/campaigns", body: bodyRequired},
{name: "campaign update", summary: "Update a campaign", method: "PATCH", path: "/campaigns/{id}", body: bodyRequired},
{name: "campaign delete", summary: "Delete a campaign", method: "DELETE", path: "/campaigns/{id}"},
{name: "campaign steps", summary: "List a campaign's sequence steps", method: "GET", path: "/campaigns/{id}/steps"},
{name: "campaign add-step", summary: "Add a sequence step", method: "POST", path: "/campaigns/{id}/steps", body: bodyRequired},
{name: "campaign update-step", summary: "Update a sequence step", method: "PATCH", path: "/campaigns/{id}/steps/{child}", body: bodyRequired, child: "step"},
{name: "campaign delete-step", summary: "Delete a sequence step", method: "DELETE", path: "/campaigns/{id}/steps/{child}", child: "step"},
{name: "campaign senders", summary: "Show the campaign's sender pool and weights", method: "GET", path: "/campaigns/{id}/senders"},
{name: "campaign set-senders", summary: "Replace the campaign's sender pool", method: "PUT", path: "/campaigns/{id}/senders", body: bodyRequired},
{name: "campaign preflight", summary: "Run the pre-send checks without sending", method: "POST", path: "/campaigns/{id}/preflight"},
{name: "campaign test-email", summary: "Send the campaign as a test to an address you name", method: "POST", path: "/campaigns/{id}/test-email", body: bodyRequired, sends: true},
{name: "campaign start", summary: "Start the campaign. This sends real mail", method: "POST", path: "/campaigns/{id}/start", sends: true},
{name: "campaign stop", summary: "Stop the campaign", method: "POST", path: "/campaigns/{id}/stop"},
{name: "campaign logs", summary: "The campaign's send log", method: "GET", path: "/campaigns/{id}/logs", query: []string{"limit", "cursor"}},
// Contacts.
{name: "contact list", summary: "List or search contacts; --data carries the filter body", method: "POST", path: "/contacts/search", body: bodyOptional, query: []string{"limit", "cursor"}},
{name: "contact get", summary: "Get one contact with suppression state", method: "GET", path: "/contacts/{id}"},
{name: "contact lookup", summary: "Resolve a contact by email address", method: "GET", path: "/contacts/lookup", query: []string{"email"}},
{name: "contact create", summary: "Create a contact", method: "POST", path: "/contacts", body: bodyRequired},
{name: "contact update", summary: "Update a contact", method: "PATCH", path: "/contacts/{id}", body: bodyRequired},
{name: "contact delete", summary: "Delete a contact", method: "DELETE", path: "/contacts/{id}"},
{name: "contact timeline", summary: "Everything that happened to a contact, newest first", method: "GET", path: "/contacts/{id}/timeline", query: []string{"limit", "cursor"}},
{name: "contact emails", summary: "Emails sent to a contact", method: "GET", path: "/contacts/{id}/emails", query: []string{"limit", "cursor"}},
{name: "contact notes", summary: "List a contact's notes", method: "GET", path: "/contacts/{id}/notes"},
{name: "contact add-note", summary: "Add a note to a contact", method: "POST", path: "/contacts/{id}/notes", body: bodyRequired},
{name: "contact custom-fields", summary: "The distinct custom field keys in use", method: "GET", path: "/contacts/custom-fields"},
{name: "contact import-preview", summary: "Preview a bulk import without writing", method: "POST", path: "/contacts/import/preview", body: bodyRequired},
{name: "contact import-commit", summary: "Commit a previewed bulk import", method: "POST", path: "/contacts/import/commit", body: bodyRequired},
{name: "contact export", summary: "Export contacts", method: "POST", path: "/contacts/export", body: bodyOptional},
// Mailboxes (email accounts).
{name: "mailbox list", summary: "List connected mailboxes", method: "GET", path: "/emails", query: []string{"limit", "cursor", "q"}},
{name: "mailbox get", summary: "Get one mailbox", method: "GET", path: "/emails/{id}"},
{name: "mailbox update", summary: "Update mailbox settings (limits, tags, timezone, signature)", method: "PATCH", path: "/emails/{id}", body: bodyRequired},
{name: "mailbox delete", summary: "Disconnect a mailbox", method: "DELETE", path: "/emails/{id}"},
{name: "mailbox auth-check", summary: "Check the mailbox's SPF, DKIM and DMARC", method: "GET", path: "/emails/{id}/auth-check"},
{name: "mailbox sync", summary: "The mailbox's sync state and backfill progress", method: "GET", path: "/emails/{id}/sync"},
{name: "mailbox behavior", summary: "The mailbox's human-sending ranges", method: "GET", path: "/emails/{id}/behavior"},
{name: "mailbox set-behavior", summary: "Update the mailbox's sending behaviour", method: "PUT", path: "/emails/{id}/behavior", body: bodyRequired},
{name: "mailbox verify", summary: "Verify an email address without sending", method: "POST", path: "/emails/verify", body: bodyRequired},
{name: "mailbox send", summary: "Send one email from this mailbox. This sends real mail", method: "POST", path: "/emails/{id}/send", body: bodyRequired, sends: true},
{name: "mailbox warmup-start", summary: "Start warming the mailbox", method: "POST", path: "/emails/{id}/warmup/start"},
{name: "mailbox warmup-pause", summary: "Pause warmup", method: "POST", path: "/emails/{id}/warmup/pause"},
{name: "mailbox warmup-resume", summary: "Resume warmup", method: "POST", path: "/emails/{id}/warmup/resume"},
{name: "mailbox warmup-stop", summary: "Stop warmup", method: "POST", path: "/emails/{id}/warmup/stop"},
{name: "mailbox warmup-status", summary: "The mailbox's warmup pool ban status", method: "GET", path: "/emails/{id}/warmup/ban-status"},
// Unified inbox.
{name: "inbox list", summary: "List inbox messages", method: "GET", path: "/unibox", query: []string{"limit", "cursor", "address", "direction", "from", "subject", "unseen", "awaiting_reply", "since", "until"}},
{name: "inbox count", summary: "The unseen message count", method: "GET", path: "/unibox/count"},
{name: "inbox overview", summary: "Per-mailbox and per-tag inbox rollup", method: "GET", path: "/unibox/overview"},
{name: "inbox thread", summary: "One conversation thread", method: "GET", path: "/unibox/thread", query: []string{"thread_id", "email_id", "limit", "cursor"}},
{name: "inbox seen", summary: "Mark messages seen or unseen", method: "PATCH", path: "/unibox/seen", body: bodyRequired},
{name: "inbox reply", summary: "Reply in a thread. This sends real mail", method: "POST", path: "/unibox/reply", body: bodyRequired, sends: true},
{name: "inbox compose", summary: "Compose a new email. This sends real mail", method: "POST", path: "/unibox/compose", body: bodyRequired, sends: true},
{name: "inbox drafts", summary: "List the AI agent's drafts awaiting approval", method: "GET", path: "/unibox/agent-drafts"},
{name: "inbox approve-draft", summary: "Approve an agent draft, which sends it", method: "POST", path: "/unibox/agent-drafts/{id}/approve", sends: true},
{name: "inbox discard-draft", summary: "Discard an agent draft", method: "POST", path: "/unibox/agent-drafts/{id}/discard"},
{name: "inbox scheduled", summary: "List scheduled sends", method: "GET", path: "/unibox/scheduled"},
{name: "inbox cancel-scheduled", summary: "Cancel a scheduled send", method: "DELETE", path: "/unibox/scheduled/{id}"},
// Analytics and audit.
{name: "analytics dashboard", summary: "The dashboard numbers", method: "GET", path: "/analytics/dashboard"},
{name: "analytics deliverability", summary: "Bounces, complaints and placement", method: "GET", path: "/analytics/deliverability"},
{name: "analytics warmup", summary: "Warmup analytics", method: "GET", path: "/analytics/warmup"},
{name: "analytics accounts", summary: "Per-mailbox analytics", method: "GET", path: "/analytics/accounts"},
{name: "analytics account", summary: "One mailbox's analytics", method: "GET", path: "/analytics/accounts/{id}"},
{name: "analytics campaign", summary: "One campaign's analytics", method: "GET", path: "/analytics/campaigns/{id}"},
{name: "analytics campaign-daily", summary: "One campaign's daily series", method: "GET", path: "/analytics/campaigns/{id}/daily"},
{name: "analytics campaign-hourly", summary: "One campaign's hourly series", method: "GET", path: "/analytics/campaigns/{id}/hourly"},
{name: "analytics usage", summary: "API and plan usage", method: "GET", path: "/analytics/usage"},
{name: "analytics audit-logs", summary: "The organization's audit trail", method: "GET", path: "/audit-logs", query: []string{"limit", "cursor"}},
// Organization-wide sending settings.
{name: "settings outreach", summary: "The organization's outreach and suppression settings", method: "GET", path: "/outreach/settings"},
{name: "settings set-outreach", summary: "Update the outreach settings", method: "PATCH", path: "/outreach/settings", body: bodyRequired},
// Webhooks.
{name: "webhook list", summary: "List webhook endpoints", method: "GET", path: "/webhooks"},
{name: "webhook create", summary: "Create a webhook endpoint", method: "POST", path: "/webhooks", body: bodyRequired},
{name: "webhook update", summary: "Update a webhook endpoint", method: "PATCH", path: "/webhooks/{id}", body: bodyRequired},
{name: "webhook delete", summary: "Delete a webhook endpoint", method: "DELETE", path: "/webhooks/{id}"},
{name: "webhook verify", summary: "Send a verification ping to the endpoint", method: "POST", path: "/webhooks/{id}/verify"},
{name: "webhook rotate-secret", summary: "Rotate the endpoint's signing secret", method: "POST", path: "/webhooks/{id}/rotate-secret"},
{name: "webhook deliveries", summary: "Recent deliveries across endpoints", method: "GET", path: "/webhooks/deliveries", query: []string{"limit", "cursor"}},
{name: "webhook event-types", summary: "Every event type a webhook can subscribe to", method: "GET", path: "/webhooks/event-types"},
// API keys (self-service).
{name: "apikey list", summary: "List the organization's API keys", method: "GET", path: "/api-keys"},
{name: "apikey get", summary: "Get one API key", method: "GET", path: "/api-keys/{id}"},
{name: "apikey create", summary: "Create an API key; the secret is only ever in this response", method: "POST", path: "/api-keys", body: bodyRequired},
{name: "apikey update", summary: "Update an API key's name, scopes or restrictions", method: "PATCH", path: "/api-keys/{id}", body: bodyRequired},
{name: "apikey revoke", summary: "Revoke an API key", method: "DELETE", path: "/api-keys/{id}"},
{name: "apikey permissions", summary: "Every grantable scope with its bit value", method: "GET", path: "/api-keys/permissions"},
// Templates.
{name: "template list", summary: "List reply templates", method: "GET", path: "/templates"},
{name: "template get", summary: "Get one template", method: "GET", path: "/templates/{id}"},
{name: "template create", summary: "Create a template", method: "POST", path: "/templates", body: bodyRequired},
{name: "template update", summary: "Update a template", method: "PATCH", path: "/templates/{id}", body: bodyRequired},
{name: "template delete", summary: "Delete a template", method: "DELETE", path: "/templates/{id}"},
// CRM.
{name: "crm pipelines", summary: "List pipelines with their stages", method: "GET", path: "/crm/pipelines"},
{name: "crm deals", summary: "Search deals; --data carries the filter body", method: "POST", path: "/crm/deals/search", body: bodyOptional},
{name: "crm tasks", summary: "Search CRM tasks; --data carries the filter body", method: "POST", path: "/crm/tasks/search", body: bodyOptional},
}
// apiFamilyOrder keeps the top-level help stable; maps iterate randomly.
var apiFamilyOrder = []string{
"me", "campaign", "contact", "mailbox", "inbox", "analytics",
"settings", "webhook", "apikey", "template", "crm",
}
// apiFamilies drives top-level dispatch and help for the typed commands.
var apiFamilies = map[string]string{
"me": "Who the API key is",
"campaign": "Campaigns and their sequences",
"contact": "Contacts, notes, and imports",
"mailbox": "Connected mailboxes and warmup",
"inbox": "The unified inbox",
"analytics": "Analytics and the audit trail",
"settings": "Organization outreach settings",
"webhook": "Webhook endpoints",
"apikey": "API keys",
"template": "Reply templates",
"crm": "Pipelines, deals, and CRM tasks",
}
// runAPIResource runs one typed command: `warmblyctl campaign start --id ...`.
func runAPIResource(ctx context.Context, family string, args []string) error {
// `me` has no subcommands; everything else does.
name := family
if family != "me" {
if len(args) == 0 {
apiFamilyUsage(os.Stderr, family)
return fmt.Errorf("`%s` needs a subcommand. Pick one from the list above.", family)
}
if args[0] == "help" || args[0] == "-h" || args[0] == "--help" {
apiFamilyUsage(os.Stdout, family)
return nil
}
name = family + " " + args[0]
args = args[1:]
}
spec, ok := lookupAPISpec(name)
if !ok {
apiFamilyUsage(os.Stderr, family)
return fmt.Errorf("unknown subcommand `%s`. Pick one from the list above.", name)
}
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.Usage = func() { apiSpecUsage(os.Stderr, spec) }
var id, child, data, idem *string
if strings.Contains(spec.path, "{id}") {
id = fs.String("id", "", "the resource id (required)")
}
if spec.child != "" {
child = fs.String(spec.child, "", "the "+spec.child+" id (required)")
}
if spec.body != bodyNone {
data = fs.String("data", "", "JSON request body: a literal, `-` for stdin, or @file")
}
if spec.method != "GET" {
idem = fs.String("idempotency-key", "", "Idempotency-Key header for a safely retryable write")
}
queryVals := make(map[string]*string, len(spec.query))
for _, q := range spec.query {
queryVals[q] = fs.String(q, "", "query parameter "+q)
}
if err := fs.Parse(args); err != nil {
return err
}
if err := noExtraArgs(fs); err != nil {
return err
}
path := spec.path
if id != nil {
if strings.TrimSpace(*id) == "" {
return fmt.Errorf("--id is required. Example:\n %s", specExample(spec))
}
path = strings.ReplaceAll(path, "{id}", url.PathEscape(strings.TrimSpace(*id)))
}
if child != nil {
if strings.TrimSpace(*child) == "" {
return fmt.Errorf("--%s is required. Example:\n %s", spec.child, specExample(spec))
}
path = strings.ReplaceAll(path, "{child}", url.PathEscape(strings.TrimSpace(*child)))
}
var body json.RawMessage
if data != nil {
parsed, err := readBodyArg(*data)
if err != nil {
return err
}
body = parsed
}
if body == nil && spec.body == bodyRequired {
return fmt.Errorf("--data is required: this command writes, and the body carries what to write. Example:\n %s", specExample(spec))
}
if body == nil && spec.body == bodyOptional {
body = json.RawMessage("{}")
}
query := url.Values{}
for k, v := range queryVals {
if strings.TrimSpace(*v) != "" {
query.Set(k, strings.TrimSpace(*v))
}
}
idemKey := ""
if idem != nil {
idemKey = *idem
}
client, err := newAPIClient()
if err != nil {
return err
}
payload, err := client.do(ctx, spec.method, path, query, bodyOrNil(body), idemKey)
if err != nil {
return err
}
return printJSON(payload)
}
// bodyOrNil keeps a nil RawMessage from being sent as the literal "null".
func bodyOrNil(body json.RawMessage) any {
if body == nil {
return nil
}
return body
}
func lookupAPISpec(name string) (apiSpec, bool) {
for _, s := range apiSpecs {
if s.name == name {
return s, true
}
}
return apiSpec{}, false
}
// specExample builds a copy-pasteable invocation from the spec itself, so the
// help never drifts from what the command actually accepts.
func specExample(s apiSpec) string {
parts := []string{"warmblyctl", s.name}
if strings.Contains(s.path, "{id}") {
parts = append(parts, "--id <uuid>")
}
if s.child != "" {
parts = append(parts, "--"+s.child+" <uuid>")
}
if s.body == bodyRequired {
parts = append(parts, `--data '{...}'`)
}
return strings.Join(parts, " ")
}
func apiFamilyUsage(w *os.File, family string) {
fmt.Fprintf(w, "%s, over the public API.\n\nUsage:\n warmblyctl %s <subcommand> [flags]\n\nSubcommands:\n", apiFamilies[family], family)
for _, s := range apiSpecs {
if !strings.HasPrefix(s.name, family+" ") {
continue
}
fmt.Fprintf(w, " %-18s %s\n", strings.TrimPrefix(s.name, family+" "), s.summary)
}
fmt.Fprintf(w, "\nEvery command prints the API's JSON response. These need WARMBLY_API_KEY, and\nWARMBLY_API_URL when the instance is not the hosted service.\nRun `warmblyctl %s <subcommand> --help` for one command's flags.\n", family)
}
func apiSpecUsage(w *os.File, s apiSpec) {
fmt.Fprintf(w, "%s.\n\nUsage:\n %s\n\nCalls:\n %s /v1%s\n", s.summary, specExample(s), s.method, s.path)
if len(s.query) > 0 {
fmt.Fprintf(w, "\nQuery flags: --%s\n", strings.Join(s.query, ", --"))
}
if s.body == bodyOptional {
fmt.Fprint(w, "\n--data is optional; omitting it sends {}.\n")
}
if s.sends {
fmt.Fprint(w, "\nThis command puts real mail on the wire.\n")
}
}