mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-05 16:02:48 +00:00
361 lines
20 KiB
Go
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")
|
|
}
|
|
}
|