output and scripting.md

Output and scripting

How the MachineMail CLI picks tables or JSON, NDJSON streams, --json, --fields and --all, errors and exit codes, CI behavior and scripting patterns with jq.

The CLI picks its output once per run, from where stdout goes:

stdout Output
A terminal Tables for lists (with a page footer), a detail pane for one object, a success line for sends and deletes. Spinners and prompts draw on stderr.
A pipe or a file, or with --json One JSON document per command: the API response, untouched apart from --fields. Streams (tail, events stream) print NDJSON, one JSON object per line.

So the same command reads well for a person and parses cleanly for a script:

machinemail messages list support-agent@machinemail.dev
ID                              FROM               SUBJECT             TRUST    RECEIVED
────────────────────────────────────────────────────────────────────────────────────────────────────
msg_01m4gnfsw200001e83690x4dm8  noreply@acme.dev   Your Acme verific…  UNKNOWN  2026-10-09 15:46 UTC
msg_01m4gnf4m40000ar9pc867d5at  → ada@example.com  Hello               -        2026-10-09 15:45 UTC
2 messages
machinemail messages list support-agent@machinemail.dev | jq -r '.messages[].subject'
Your Acme verification code
Hello

Tables fit the terminal width: on a narrow terminal the least useful columns drop first, and ids and addresses are never cut. Dates print as UTC. Nothing is redrawn after it prints, so the output stays in your scrollback.

Shaping JSON

--json forces JSON on a terminal too.

--fields a,b.c keeps only those fields, with dot paths for nested ones. On a list it applies to each item; on one object, to the object. In a table the fields become the columns.

machinemail messages list "$INBOX" --fields message_id,from,subject
{
  "count": 2,
  "limit": 50,
  "messages": [
    {
      "message_id": "msg_01m4gnfsw200001e83690x4dm8",
      "from": "noreply@acme.dev",
      "subject": "Your Acme verification code"
    },
    {
      "message_id": "msg_01m4gnf4m40000ar9pc867d5at",
      "from": "support-agent@machinemail.dev",
      "subject": "Hello"
    }
  ]
}

--all on a list command follows next_page_token and returns every page merged into one document, so jq sees one array:

machinemail inboxes list --all | jq -r '.inboxes[].inbox_id'

Without --all, list commands take --limit and --page-token as the API does.

Workflow commands print what a script needs. inbox new prints only the address when piped (--json for the whole inbox); wait --otp prints only the code. Both are made for $(...):

INBOX=$(machinemail inbox new --ephemeral --ttl 30m)
CODE=$(machinemail wait --inbox "$INBOX" --otp)

Streams: NDJSON

machinemail tail and machinemail events stream run until you stop them. Piped, they print one JSON object per line, {id, event, data}, flushed as each event arrives:

machinemail tail --inbox "$INBOX" | jq -c '{event, from: .data.message.from, subject: .data.message.subject}'
{"event":"message.received","from":"noreply@acme.dev","subject":"Your Acme verification code"}
{"event":"message.sent","from":"support-agent@machinemail.dev","subject":"Ping"}

tail reconnects on its own and resumes from the last event it saw. To pick up where an earlier run stopped, pass the last id you processed as --last-event-id; --max n stops after n events (exit 0).

Raw and binary output

messages raw (the RFC 5322 source), messages download (an attachment), domains zone-file and inboxes export write the body as it comes. -o/--output <file> writes it to a file instead and prints {path, bytes, content_type}; --output <dir> keeps the server's filename. The file is written through a temporary file with mode 0600 and renamed, so it is whole or absent. A binary body is refused on a terminal unless you pass --output or redirect stdout.

Input

Flags take typed values: integers and numbers are checked, booleans take --flag, --flag=false or --no-flag, arrays repeat the flag, and objects take JSON (--metadata '{"team":"ops"}'). --ttl-seconds null sends JSON null to clear a nullable field.

-d/--data gives the whole request body as JSON, @file, or - for stdin. Field flags override its keys:

jq -n --arg to ada@example.com '{to: [$to], subject: "Report", text: "Attached."}' \
  | machinemail messages send "$INBOX" --data -

The CLI reads stdin only when you ask for it with -: --data -, --text -, --api-key -, login --key -. An agent that runs it with an open, silent stdin never hangs.

Errors

Errors always go to stderr, so stdout carries only results. On a terminal they print as a short box with hints. When stdout is piped, with --json, or when stderr is not a terminal, an error is one line of JSON: the API's error envelope with the HTTP status, or a CLI error (type usage or cli) with hints.

machinemail messages get "$INBOX" msg_nope
{"error":{"type":"not_found","message":"Message not found: msg_nope"},"status":404}
{"error":{"type":"usage","message":"Unknown action \"lst\" for messages"},"hints":["Did you mean list?"]}

Exit codes

Code Meaning
0 Success.
1 API error not listed below (400, 409, 422, 5xx after retries), an unexpected failure, or a declined confirmation.
2 Usage: unknown command or flag, missing argument, bad value, or a delete without --yes and no terminal to ask on. Nothing was sent.
3 Auth: no API key, or the API answered 401 or 403. Also a sign-in denied in the browser.
4 Not found (404).
5 Rate limited (429), after retries.
6 Network: the API could not be reached (DNS, connection refused, TLS, timeout).
7 The config file is unreadable or invalid.
8 Timed out: wait reached --timeout, or a sign-in or claim code expired.
130 Interrupted (Ctrl-C).

Branch on them in a script:

if CODE=$(machinemail wait --inbox "$INBOX" --otp --timeout 2m); then
  echo "code: $CODE"
else
  case $? in
    8) echo "no code within 2 minutes" >&2 ;; 
    3) echo "check MACHINEMAIL_API_KEY" >&2 ;; 
    *) echo "wait failed" >&2 ;;
  esac
  exit 1
fi

Retries

Rate-limited requests (429) are retried on every method, and server errors (500, 502, 503, 504) and network failures on GET, PUT and DELETE, up to 3 times. The CLI honors Retry-After, else waits 0.5 s, 1 s, 2 s and so on with jitter (at most 8 s). A request that sends mail is never retried after a server error, so a message is not sent twice. MACHINEMAIL_MAX_RETRIES changes the count; 0 turns retries off.

Deletes and confirmation

Every delete asks first on an interactive terminal: for inboxes, domains, pods, API keys and profiles you type the id, for smaller deletes (a message, a draft, a list entry) you answer y/N. Without a terminal to ask on (stdin or stderr not a terminal, or CI set) a delete exits 2 and sends nothing unless you pass -y/--yes:

machinemail inboxes delete "$INBOX" --yes

Color, spinners and CI

From an agent

An agent running shell commands gets the JSON behavior automatically, because its stdout is not a terminal. A few habits keep it reliable: