---
title: "Sending and tracking email"
description: "Send, batch, translate, schedule and cancel mail with the `emails` commands, then follow its delivery, opens and clicks with `tracking`."
url: "https://openemail.uk/docs/cli/emails"
area: "CLI"
category: "Commands by area"
---

# 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.

| send | emails 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.

| Command | What 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 list | One 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.

| Command | What it does |
| --- | --- |
| openemail tracking list | One 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-stats | The 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 <billing@acme.com>' \
  --to ada@example.de \
  --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.json
jq -r .html preview.json
openemail emails send --from billing@acme.com --to ada@example.de \
  --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": "billing@acme.com", "to": "ada@example.com", "subject": "Receipt 4192", "text": "Thanks for your order." },
  { "from": "billing@acme.com", "to": "bob@example.com", "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.json
jq '{ sent, failed }' result.json
jq -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 you@acme.com --to team@acme.com --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:00Z
openemail 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 billing@acme.com --all | jq -r .id
openemail emails get msg_3f9a1c07d2b84e6a9c5b1f20
openemail 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 .subject
openemail 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 --help
openemail emails send --help
openemail tracking list-opens --help --json
```

- [Mail](https://openemail.uk/docs/cli/mail.md): The hand-written send, with reading, searching and filing mail.
- [SDK: send an email](https://openemail.uk/docs/sdk/emails/send.md): The same call from TypeScript, field by field.
- [SDK: open and click tracking](https://openemail.uk/docs/sdk/emails/tracking.md): What each tracking field means, and how opens are counted.
- [Scripting](https://openemail.uk/docs/cli/scripting.md): JSON output, streams, exit codes and CI.
