Files
warmbly/cmd/warmblyctl/api_resources.go
T

361 lines
20 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
idLabel string // what {id} is called in help, when it is not a uuid
}
// idPlaceholder is what the generated help shows for --id. Most resources are
// uuid-keyed; the ones that are not say so, since a user who copies the
// example verbatim would otherwise send a malformed identifier.
func (s apiSpec) idPlaceholder() string {
if s.idLabel != "" {
return "<" + s.idLabel + ">"
}
return "<uuid>"
}
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},
// Suppression list.
{name: "suppression list", summary: "The workspace suppression list: addresses and domains that get no campaign mail", method: "GET", path: "/suppressions", query: []string{"limit", "cursor", "q"}},
{name: "suppression add", summary: "Add addresses or domains to the suppression list", method: "POST", path: "/suppressions", body: bodyRequired},
{name: "suppression remove", summary: "Lift a suppression entry so the address can be emailed again", method: "DELETE", path: "/suppressions/{id}"},
// 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"},
{name: "mailbox hold", summary: "Hold the mailbox out of campaign sending", method: "POST", path: "/emails/{id}/hold"},
{name: "mailbox release", summary: "Put a held mailbox back into campaign sending", method: "POST", path: "/emails/{id}/release"},
// Unified inbox.
{name: "inbox list", summary: "List inbox messages", method: "GET", path: "/unibox", query: []string{"limit", "cursor", "address", "direction", "folder", "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},
// Agent tools: the shared AI tool registry over REST, for function-calling
// agents that do not speak MCP. --id is the tool name, not a UUID.
{name: "tool list", summary: "List the tools this key may call; --format openai emits function-calling manifests", method: "GET", path: "/ai/tools", query: []string{"format"}},
{name: "tool call", summary: "Execute one registry tool; --data carries its JSON argument object", method: "POST", path: "/ai/tools/{id}/call", body: bodyOptional, idLabel: "tool-name"},
}
// apiFamilyOrder keeps the top-level help stable; maps iterate randomly.
var apiFamilyOrder = []string{
"me", "campaign", "contact", "suppression", "mailbox", "inbox", "analytics",
"settings", "webhook", "apikey", "template", "crm", "tool",
}
// 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",
"suppression": "The suppression list: addresses and domains that get no campaign mail",
"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",
"tool": "AI agent tools (list and call the registry)",
}
// 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}") {
label := "resource id"
if spec.idLabel != "" {
label = spec.idLabel
}
id = fs.String("id", "", "the "+label+" (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 "+s.idPlaceholder())
}
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")
}
}