Skip to the documentation
CLI

Sending and tracking email

Send, batch, translate, schedule and cancel mail with the `emails` commands, then follow its delivery, opens and clicks with `tracking`.

Overview

The emails namespace is the send API as commands, one for each method of openemail.emails in the SDK. Each calls one endpoint and prints what it returns. The tracking namespace reads the opens and clicks on the mail you sent. openemail email works in place of openemail emails.

Every command here needs a sign-in, from the browser or an API key, and one of two scopes: emails:send to send, translate, cancel and reschedule, and emails:read for everything that only reads.

Which send to use

openemail send is the hand-written command on the Mail page, and it sends through emails send. It is made for a person at a terminal: it picks the sending address when you leave out --from, reads the body from a file, stdin or your editor, attaches files by path, and shows a summary to confirm before anything goes. openemail emails send takes the request body as flags, one for each field, and asks nothing, which suits a script that knows exactly what it sends.

sendemails send
--from <address>Required, like --to, unless --data holds it. send can leave it out and pick an address for you
-f, --body-file <path>No file flag for the body. Pass --html "$(cat body.html)", or the whole request in --data @email.json
-a, --attach <path>--attachments, a JSON array of files, each with a filename and base64 content, or with the fileId of a file already in Files
--at <when>--scheduled-at <when>, an ISO 8601 instant or a duration such as PT1H or P2D. send also takes short delays such as 10m, 2h and 1d
--undo <seconds>--cancellable-for-seconds <n>, from 0 to 900
--translate <language>--translate '{"to":"de"}', which also takes from, includeOriginal and subject
--template <id> --props <json>--template '{"id":"welcome","props":{"name":"Ada"}}', which can also pin a version
--draft <id>--draft-id <id>
--thread <id>--thread-id <id>
--tag <key=value>--tags <key=value>, repeated, or a JSON object

Only emails send has --tracking to turn opens or clicks off for one send, --signature, --headers for custom headers, --attachment-delivery to choose between attaching files and linking to them, and --data for the whole body as JSON, inline, from a file with @path or from stdin with -.

The two end differently. send exits with code 1 when the email comes back failed. emails send exits with code 0 whenever the API answered, so check status in what it prints.

Every emails command

send, send-batch, translate, cancel and reschedule need emails:send. list, get, list-events and get-tracking need emails:read. An email id is msg_ followed by 24 hex characters, as a send returns it.

CommandWhat it does
openemail emails send --from <value> --to <a,b>Send one email now, hold it for an undo window with --cancellable-for-seconds, or schedule it with --scheduled-at. The body is --html, --text or both, a stored --template, or a saved --draft-id
openemail emails send-batch <emails>Send up to 100 independent emails in one request, from a JSON array in a file, inline, or on stdin with -. Each item is shaped like the body of emails send and succeeds or fails on its own
openemail emails translate --to <value>Preview what a translated send would deliver, for --subject, --html or --text. Nothing is stored or sent, and it spends one AI action
openemail emails listOne page of sent emails, newest first, narrowed by --status, --from or --broadcast-id
openemail emails get <id>One sent email with each recipient’s own status, error and delivery time, and the full tracking report when it was tracked
openemail emails list-events <id>The event trail of one send, oldest first: accepted, scheduled, sent, delivered, bounced, complained, opened, clicked and the rest
openemail emails get-tracking <id>The engagement report of one send: its totals, one entry per tracked copy, and every rewritten link with its clicks
openemail emails cancel <id>Stop a queued or scheduled email before it goes. It asks you to confirm
openemail emails reschedule <id> <scheduled-at>Move a queued or scheduled email to an ISO 8601 instant, or a duration such as PT30M, from one second to 365 days out

Every tracking command

All five need emails:read. tracking get, list-opens and list-clicks take either id a message has: the msg_ id its send returned, or the tmsg_ tracking id that tracking list and webhook payloads carry.

CommandWhat it does
openemail tracking listOne page of tracked messages sent in a window, newest first, each with its full report. --opened and --clicked narrow it, and --no-opened keeps the ones nobody opened. The window is 30 days unless --days or --minutes says otherwise
openemail tracking get-statsThe numbers behind an engagement panel: messages tracked, opened and clicked, open and click rates, a time series in --grain buckets, and the top links, mail clients and countries
openemail tracking get <id>One message’s engagement report, the same document emails get-tracking returns
openemail tracking list-opens <id>The individual opens behind a message’s open count, newest first, each marked human, proxy or machine. --include-machine adds the hits that were not counted
openemail tracking list-clicks <id>The individual clicks on a message’s links, newest first, with the original url of each. --include-machine adds link scanners and collapsed repeats

tracking list and get-stats cover every tracked message the mailbox sent, including mail written in the web app and mail sent by the MCP tools or the assistant, while emails list holds the send records the API made. A report with no send record has sendId set to null.

Examples

Send from a script with an idempotency key of your own. Running it again with the same --idempotency-key prints the first email with replayed: true instead of sending a second one.

Send from a script
openemail emails send \  --from 'Acme Billing <[email protected]>' \  --to [email protected] \  --subject 'Your September invoice' \  --html '<p>The invoice is attached. Tell me if anything on it looks wrong.</p>' \  --attachments '[{"fileId":"file_6bb640f5b99e47deb758f1f5"}]' \  --tracking '{"opens":false}' \  --idempotency-key invoice:inv_2026_09_4192 \  --json | jq -r '.id + " " + .status'

Have a person read a translation before it goes. Send the approved wording as a plain --subject and --html, without --translate, or it is translated a second time. The translated html already holds your original below it, unless you pass --no-include-original.

Preview a translation, then send it
openemail emails translate --to de \  --subject 'Your September invoice' \  --html "$(cat invoice.html)" \  --json > preview.jsonjq -r .html preview.jsonopenemail emails send --from [email protected] --to [email protected] \  --subject "$(jq -r .subject preview.json)" \  --html "$(jq -r .html preview.json)"

Send a batch from a file. The command exits with code 0 whenever the batch was processed, even when some items failed, so read failed and each item’s status. Running it again with the same key replays the items that went and sends only the rest, as long as the array keeps its order.

receipts.json
[  { "from": "[email protected]", "to": "[email protected]", "subject": "Receipt 4192", "text": "Thanks for your order." },  { "from": "[email protected]", "to": "[email protected]", "subject": "Receipt 4193", "text": "Thanks for your order." }]
Send the batch
openemail emails send-batch receipts.json --idempotency-key receipts:2026-09-27 --json > result.jsonjq '{ sent, failed }' result.jsonjq -r '.items[] | select(.status == "error") | "\(.index) \(.error.code)"' result.json

Schedule an email, move it, and cancel it. --yes answers the confirmation cancel asks for, which a script cannot.

Schedule, move and cancel
ID=$(openemail send --from [email protected] --to [email protected] --subject "Standup notes" \  --body-file notes.md --at 2026-10-01T09:00:00Z --json | jq -r .id)openemail emails reschedule "$ID" 2026-10-01T13:00:00Zopenemail emails get "$ID" --json | jq -r '.status + " " + .scheduledAt'openemail emails cancel "$ID" --yes

Find the sends that failed and read what happened to one. Piped without --json, --all prints one JSON object per line.

Find failed sends
openemail emails list --status failed,partial --from [email protected] --all | jq -r .idopenemail emails get msg_3f9a1c07d2b84e6a9c5b1f20openemail emails list-events msg_3f9a1c07d2b84e6a9c5b1f20 --all --json | jq -r '.items[] | .createdAt + " " + .type'

Read a week of engagement in days that break at midnight UTC+2, list what nobody opened, and count the clicks on each link of one message.

A week of opens and clicks
openemail tracking get-stats --days 7 --offset-minutes 120 --json | jq '{ tracked, openRate, clickRate }'openemail tracking list --no-opened --days 7 --all | jq -r .subjectopenemail tracking list-clicks msg_3f9a1c07d2b84e6a9c5b1f20 --all | jq -r .url | sort | uniq -c

Scopes, codes and confirmations

  • A browser sign-in asks for scopes on the approval page, and openemail login --scopes emails:send,emails:read preselects both. A command whose scope is missing stops with exit code 4 and insufficient_scope, and names the scope.
  • send --attach with more than 5 MB of files uploads them to Files first, which also needs files:write.
  • None of these commands asks for a verification code, so a browser sign-in runs them as an API key does.
  • emails cancel asks before it cancels, and --yes answers for you. Unattended without --yes, it stops with Refusing to run unattended. Pass --yes to confirm. and exit code 2.
  • emails send, send-batch and reschedule never ask. send shows a summary and asks only in a terminal, and --yes skips that too.
  • --dry-run prints the request a command would send, sends nothing and exits with code 0. On emails translate that spends no AI action, and on emails cancel it asks nothing.

Pages of results

emails list, emails list-events, tracking list, list-opens and list-clicks read one page. --limit sets its size, from 1 to 100 with 25 by default for the two emails lists, and from 1 to 200 with 50 by default for the three tracking lists. --cursor carries on from the cursor a page printed.

  • --all reads every page and streams the items: a table on a terminal, and one JSON object per line when piped or with --ndjson.
  • --max <n> stops after that many items, and implies --all.
  • --json prints one { items, hasMore, nextCursor } document, --all included.
  • Paging is by cursor, not offset, so mail sent while you page never shifts or repeats a row.

Worth knowing

  • Each run makes its own idempotency key, which covers the retries inside that run. Running a send twice sends twice, unless both runs pass the same --idempotency-key. The same key with a different body is refused with idempotency_key_reuse and exit code 7.
  • Only queued and scheduled mail can be cancelled or moved. An immediate send with no undo window goes out inside the request, so by the time you hold its id it is usually too late, and the call ends with email_not_cancellable and exit code 6.
  • A cancelled email stays cancelled. Rescheduling changes only the time, counted from when the server receives the request for a duration, so to change the text, cancel and send again.
  • A translation that cannot be produced refuses the whole send, and nothing goes out untranslated. A translated batch holds at most 10 messages that carry translate.
  • A spent send allowance stops a send with send_quota_exceeded until the first of the month, and a spent AI allowance stops a translation with ai_quota_exceeded until midnight UTC, both with exit code 8.
  • Mail sent with an oe_test_ key is never delivered. It reads sent, with transport set to test, and is never tracked.
  • emails get-tracking and tracking get answer 404, exit code 5, for a message that carried no pixel and no rewritten link, because not tracked is not the same as not opened. Tracking follows the setting the message was sent with, so turning it on later does not reach earlier mail.
  • Every count is a floor. A reader whose mail client blocks images never counts as an open, and a click is stronger evidence of reading than an open.
  • list-opens and list-clicks answer 404 for a msg_ id with nothing tracked, but take a tmsg_ id as given, so an unknown one comes back as an empty list.
  • A key limited to some addresses sees only the mail sent from those addresses, and one that holds a whole domain covers every address on it.

Every flag

This page names the flags that matter most. openemail <command> --help lists every argument and flag a command takes, with its type, the scope it needs, its method and path, what it returns and the notes from the API reference. Add --json for the same help as one JSON document.

Terminal
openemail emails --helpopenemail emails send --helpopenemail tracking list-opens --help --json

Your inbox,
on your own terms.

Email infrastructure for businesses, AI, agents and personal email. Built for scale, privacy and control. Everything email should have had from day one.

OpenEmail

Email infrastructure for businesses, AI, agents and personal email. Built for scale, privacy and control. Everything email should have had from day one.

© 2026 OpenEmail. All rights reserved.