Mailhooks CLI
A command-line interface designed for AI agents and shell scripts. Every command emits JSON to stdout, so it composes cleanly with jq, pipelines, and LLM tool loops.
Overview
The Mailhooks CLI (@mailhooks/cli) is a thin wrapper over the Mailhooks SDK that makes every operation available from the terminal. It's built for agentic systems: wait for emails, extract verification codes, download attachments, and manage inboxes without writing a line of application code.
JSON-first output
Every command emits structured JSON. Auto-compact when piped.
wait-for primitive
Block until a matching email arrives. Built for agent loops.
Multi-profile auth
Named profiles for prod, multiple tenants.
Structured errors
JSON errors to stderr with exit codes. Machine-parseable.
Install
npm install -g @mailhooks/cli
# or invoke without install:
npx @mailhooks/cli Requires Node.js 18+. The CLI depends on @mailhooks/sdk and commander.
Quick Start
# Install globally
npm install -g @mailhooks/cli
# Or run without installing
npx @mailhooks/cli whoami
# Store your API key
mailhooks login --api-key mh_live_...
# List emails
mailhooks emails list --unread --per-page 10
# Wait for a specific email
mailhooks emails wait-for --subject "Verify your email" --timeout 60000Configuration
Credentials are stored in named profiles — one config file can hold keys for production, or multiple tenants. Each invocation resolves credentials in this order:
Profile selection: --profile <name> flag → MAILHOOKS_PROFILE env → currentProfile in config → none
API key: --api-key flag → MAILHOOKS_API_KEY env → selected profile's apiKey → error
Base URL: --base-url flag → MAILHOOKS_API_URL env → profile baseUrl → https://mailhooks.dev/api
| Env var | Flag | Purpose |
|---|---|---|
MAILHOOKS_API_KEY | --api-key | API key |
MAILHOOKS_API_URL | --base-url | Override API base URL |
MAILHOOKS_PROFILE | --profile | Which stored profile to use |
MAILHOOKS_CONFIG_PATH | — | Override config file path |
Login & Profiles
# Store credentials (verified against the API by default)
mailhooks login --api-key mh_live_abc123
# Multiple profiles — separate tenants or environments
mailhooks login --profile prod --api-key mh_prod_...
mailhooks login --profile client1 --api-key mh_client1_...
# Pipe from a secret manager
op read "op://vault/mailhooks/api-key" | mailhooks login --profile prod
# Switch the active profile
mailhooks profiles use prodGlobal flags
--pretty/--no-pretty— override JSON formatting. Default: pretty on a TTY, compact when piped. Agents get compact output automatically.--help— show command help--version— print CLI version
0600. This protects against other users on the machine, but anything running as your user can read it — same threat model as your SSH key. Don't use stored profiles in shared or untrusted environments; prefer MAILHOOKS_API_KEY injected from a secret manager.Output Contract
| Condition | stdout | Exit code |
|---|---|---|
| Success | JSON | 0 |
| API error | — | 1 (stderr: {"error","code"}) |
| Usage error | — | 2 (stderr: code: "usage") |
| Timeout (wait-for) | — | 124 |
| Login verify failed | — | 1 (code: "verify_failed") |
Error objects on stderr: {"error": "<message>", "code": "<code>"}. Always check the exit code before parsing stdout — agents that assume success and try to jq an error message will get confused.
The Agent Loop
The core pattern: wait for an email, read its body, then mark it as handled.
# The canonical agent loop: wait → read → mark done
EMAIL=$(mailhooks emails wait-for --subject "Order Confirmation" --timeout 60000)
ID=$(echo "$EMAIL" | jq -r '.id')
mailhooks emails content "$ID" --text # plain-text body
mailhooks emails mark-read "$ID" > /dev/null124 from wait-for means no matching email arrived in time. Handle this as a retriable condition, not a hard failure.Command Reference
Auth Commands
mailhooks login
Store an API key under a named profile. Verified against the API by default.
mailhooks login --api-key mh_live_... [--profile ] [--base-url ] [--skip-verify] Without --profile, writes to a profile called default. Accepts the key via flag, stdin pipe, or a hidden TTY prompt.
mailhooks logout
mailhooks logout # removes the currently selected profile
mailhooks logout --profile prod # removes a specific profile
mailhooks logout --all # nuke the whole config filemailhooks whoami
Shows active credentials (masked), source resolution, profile name, and available domains. When unauthenticated, returns {authenticated: false} with exit 0 — useful as a health check at agent startup.
{
"authenticated": true,
"apiKey": "mh_xxx…yyyy",
"baseUrl": "https://mailhooks.dev/api",
"profile": "default",
"currentProfile": "default",
"profiles": ["default", "prod"],
"source": {
"apiKey": "profile",
"baseUrl": "default",
"profile": "config"
},
"domains": [
{ "id": "...", "domain": "example.com", "status": "ACTIVE",
"verified": true, "isDefault": true, "environment": "production" }
],
"configPath": "/home/user/.config/mailhooks/config.json"
}The domains[] list is useful for agent discovery: an agent can call whoami to find out which domains it can receive mail at before constructing inbox addresses.
mailhooks profiles list / profiles use <name>
mailhooks profiles list
# → { currentProfile, profiles: [{name, apiKey (masked), baseUrl, current}] }
mailhooks profiles use prod
# → { ok: true, currentProfile: "prod" }Email Commands
mailhooks emails list
List emails (paginated, most-recent-first by default).
mailhooks emails list --from [email protected] --unread --per-page 10Flags: --from --to --subject --since --until --read --unread --page --per-page --sort-field --sort-order
Output: { data: Email[], currentPage, perPage, totalItems, totalPages, hasNextPage }
mailhooks emails get <id>
Fetch a single email's metadata (headers, sender, recipients, attachments — not the body).
mailhooks emails get em_123 --mark-readmailhooks emails content <id>
Fetch the HTML + text body. Raw modes pipe cleanly into other tools.
# JSON with both bodies
mailhooks emails content em_123
# → { "html": "...
", "text": "..." }
# Raw text only (pipes cleanly into grep, LLM prompts, etc.)
mailhooks emails content em_123 --text
# Raw HTML only
mailhooks emails content em_123 --htmlmailhooks emails wait-for
Poll until a matching email arrives. The main primitive for agents that trigger on inbound mail — magic links, verification codes, confirmations.
mailhooks emails wait-for \
--from [email protected] \
--subject "verification" \
--timeout 120000 \
--poll-interval 2000 \
--lookback 5000| Flag | Default | Purpose |
|---|---|---|
--timeout <ms> | 30000 | Give up after this long. Exit 124 on timeout. |
--poll-interval <ms> | 1000 | How often to check. |
--initial-delay <ms> | 0 | Wait before first check. |
--lookback <ms> | 10000 | Consider emails this far back on first check. Avoids matching stale emails. |
mailhooks emails mark-read <id> / mark-unread <id>
Update read state. Returns the updated email.
mailhooks emails delete <id>
Permanently delete an email and its attachments. Returns { id, deleted: true }. Note: does not refund quota.
mailhooks emails download-eml <id>
Download the raw .eml source (RFC 822).
# Save to a file
mailhooks emails download-eml em_123 -o /tmp/msg.eml
# → { "path": "/tmp/msg.eml", "bytes": 12345 }
# Pipe raw bytes to stdout
mailhooks emails download-eml em_123 > msg.emlmailhooks emails download-attachment <emailId> <attachmentId>
Download a specific attachment. Attachment IDs come from emails get → .attachments[].id.
mailhooks emails download-attachment em_123 att_456 -o invoice.pdfParse EML
Parse an EML file into structured JSON. Useful for BYOB (bring-your-own-bucket) setups where the raw EML is in your own storage.
# Parse a raw EML file into structured JSON
mailhooks parse-eml /path/to/msg.eml
# Or from stdin
cat msg.eml | mailhooks parse-emlOutput: { from, to[], subject, body, html?, attachments[], headers, date? }
Listen Command
mailhooks listenconnects to the Mailhooks real-time SSE stream and forwards email events to a local HTTP endpoint — like stripe listen --forward-toor ngrok, but for Mailhooks.
SSE streaming
Real-time events via Server-Sent Events.
Local forwarding
POSTs each event to your local webhook route.
Signature verification
Optional HMAC-SHA256 signing with --secret.
Basic usage
# Forward all email events to localhost:3000/webhooks
mailhooks listen
# Custom endpoint
mailhooks listen --forward-to http://localhost:8080/api/mailhooks
# With webhook signature verification
mailhooks listen --secret whsec_dev_secret
# Distributed mode (load-balanced SSE)
mailhooks listen --mode distributed --forward-to http://localhost:4000/hooks
# Quiet mode (no stdout output)
mailhooks listen --no-printHow it works
- Connects to the Mailhooks SSE stream using your API key.
- On each
email.receivedoremail.updatedevent, fetches the full email payload and POSTs it to your local URL. - If
--secretis provided, each request includes anX-Webhook-Signatureheader containing the HMAC-SHA256 of the body. Verify it server-side withverifyWebhookSignature()from the SDK. - Auto-reconnects on disconnect (disable with
--no-reconnect). - Runs until Ctrl+C. Prints connection status to stderr; optionally emits event JSON to stdout (on by default in a TTY).
Forwarded request format
Each forwarded request contains the full email payload (matching real Mailhooks webhook format), with headers:
Content-Type: application/jsonX-Mailhooks-Event: email.received(oremail.updated)X-Webhook-Signature(if--secretis set)
{
"id": "em_abc123",
"from": "[email protected]",
"to": ["[email protected]"],
"subject": "Hello",
"body": "Plain text body...",
"html": "HTML body...
",
"attachments": [],
"receivedAt": "2025-01-15T10:30:00.000Z"
}Flags
| Flag | Default | Purpose |
|---|---|---|
-f, --forward-to <url> | http://localhost:3000/webhooks | Local URL to forward events to |
--mode <mode> | broadcast | SSE mode: broadcast (all events) or distributed (load-balanced) |
--no-reconnect | (reconnect on) | Disable automatic reconnection |
--reconnect-delay <ms> | 5000 | Delay between reconnection attempts |
--secret <secret> | — | Webhook signing secret. Adds X-Webhook-Signature header |
--print | auto (TTY=on) | Print event JSON to stdout |
--no-print | — | Suppress event JSON output |
Local dev with Next.js
The most common pattern: run your app in one terminal, forward Mailhooks events in another. Your webhook route receives real email events locally.
# Terminal 1: your app
npm run dev
# Terminal 2: forward Mailhooks events
mailhooks listen --forward-to http://localhost:3000/api/mailhooks/webhook --secret whsec_dev_secret
# Your /api/mailhooks/webhook route receives real Mailhooks events locallyemail.received events are enriched — the CLI fetches full email metadata + content from the API before forwarding, so your local endpoint receives the same payload shape as a real Mailhooks webhook.email.updated events forward the raw SSE payload as-is.Patterns
Extract a one-time code
# Wait for a one-time code, extract it, move on
EMAIL=$(mailhooks emails wait-for --subject "Your login code" --timeout 60000) || exit 1
ID=$(echo "$EMAIL" | jq -r '.id')
CODE=$(mailhooks emails content "$ID" --text | grep -oE '[0-9]{6}' | head -1)
mailhooks emails mark-read "$ID" > /dev/null
echo "$CODE"Drain an inbox by sender
# Mark all unread emails from a sender as read
mailhooks emails list --from [email protected] --unread --per-page 100 \
| jq -r '.data[].id' \
| while read id; do mailhooks emails mark-read "$id" > /dev/null; doneMulti-tenant agents
# An agent operating on behalf of different tenants
for tenant in acme globex initech; do
mailhooks --profile "$tenant" emails list --unread --per-page 50
doneDiscover available domains
# Discover available inbox domains before constructing an address
DOMAIN=$(mailhooks whoami | jq -r '.domains[] | select(.verified and .isDefault) | .domain' | head -1)
echo "agent+$(uuidgen)@$DOMAIN"Stream events to a local dev server
# Terminal 1: your app
npm run dev
# Terminal 2: forward Mailhooks events
mailhooks listen --forward-to http://localhost:3000/api/mailhooks/webhook --secret whsec_dev_secret
# Your /api/mailhooks/webhook route receives real Mailhooks events locallyUse --secret to match your production webhook verification in dev. Works with any framework — Next.js, Express, FastAPI, etc.
CLI vs SDK vs MCP
| Feature | CLI | SDK | MCP Server |
|---|---|---|---|
| Use case | Agents, scripts, terminal | Application code | LLM tool-calling agents |
| Install | npm i -g @mailhooks/cli | npm i @mailhooks/sdk | mcp-mailhooks |
| Language | Any (JSON CLI) | TypeScript / Node | Any (MCP protocol) |
| Output format | JSON to stdout | Typed objects | Tool responses |
| wait-for | Built-in | Built-in | Built-in |
| listen (SSE → local) | Built-in | Via RealtimeResource | No |
| Multi-profile auth | Yes | Manual | Single key |
| No code required | Shell only | Import + code | Config only |
Troubleshooting
Exit code 2 with "missing_api_key"
No API key found. Run mailhooks login, set MAILHOOKS_API_KEY, or pass --api-key.
Exit code 124 from wait-for
Timeout — no matching email arrived. Increase --timeout, widen --lookback, or check that the email was actually sent to a verified domain.
Exit code 1 with "verify_failed"
The API key you passed to login was rejected. Check the key is correct and hasn't been revoked. Use --skip-verify to store it anyway (not recommended).
JSON looks wrong when piped
The CLI detects whether stdout is a TTY and switches between pretty and compact JSON. If something in between (e.g. a pseudo-TTY in a container runner) tricks the detection, pass --no-pretty explicitly.