---
title: "tmail"
description: "Agent-first disposable email CLI in Rust: mint throwaway inboxes, block until a verification code arrives, and send outbound mail over SMTP. JSON-only output, stable exit codes, never prompts."
slug: "tmail"
tags: ["Rust", "CLI", "Email", "Agents"]
url: "https://github.com/raymond-UI/tmail"
repo: "raymond-UI/tmail"
---


A single-binary CLI for disposable email, built for agents rather than humans. Mint a real throwaway inbox, block until the verification code lands, and pull it out as JSON, without a browser or a polling loop.

<Callout title="Status" tone="warning">
Under active construction. The CLI contract below is stable in `DESIGN.md`, but surfaces may still shift. Follow the [issue tracker](https://github.com/raymond-UI/tmail/issues).
</Callout>

## Features

- **Mint** — Fresh, real disposable inboxes via mail.tm
- **Blocking reads** — `wait` and `otp` hold until a matching message arrives, so no polling loop in your agent
- **Code extraction** — `otp` renders HTML bodies to text, collapses separators (`481-920` → `481920`), and pulls the code
- **Send** — Outbound mail through your own SMTP account, with CC/BCC, attachments, and HTML bodies
- **Stateless mode** — `--stateless` returns a portable handle instead of writing local state
- **Agent contract** — JSON-only stdout, stable exit codes, never prompts for input

## Installation

<ToolInstall cmd="curl --proto '=https' --tlsv1.2 -LsSf https://raw.githubusercontent.com/raymond-UI/tmail/main/install.sh | sh" />

Set `TMAIL_VERSION` to pin a release, or `TMAIL_INSTALL_DIR` to change the install location.

From source:

<ToolInstall cmd="cargo install --git https://github.com/raymond-UI/tmail" />

Prebuilt binaries for macOS (arm64/x86_64), Linux (x86_64/aarch64/musl), and Windows (x86_64) are on the [releases page](https://github.com/raymond-UI/tmail/releases).

## Usage

### Mint an inbox

```bash
tmail new
```

```json
{
  "id": "a1b2c3",
  "address": "k7f2x9@punkproof.com",
  "provider": "mail.tm",
  "createdAt": "2026-06-27T18:40:00Z"
}
```

### Sign up and grab the code

`otp` blocks until a message matching the filters arrives, then extracts the verification code:

```bash
addr=$(tmail new | jq -r .address)
# ... drive the signup flow with $addr ...
code=$(tmail otp "$addr" --from noreply@target.com --timeout 180 | jq -r .code)
```

```json
{
  "code": "481920",
  "msgId": "msg_01",
  "from": "noreply@github.com",
  "matchedBy": "default-digits"
}
```

Extraction scans the text body for the first `\b(\d{4,8})\b` run near keywords like *code*, *otp*, *verify*, or *pin*, then falls back to the first standalone 4–8 digit run. Override with `--pattern <regex>` or `--len <n>`. If a message matches the filter but yields no code, you get exit `9 NO_MATCH` rather than a silent empty string.

### Wait for any message

Same filters as `otp`, but returns the full message instead of a code:

```bash
tmail wait "$addr" --subject "Reset your password" --timeout 120
```

Baseline is the inbox creation time (minus a clock-skew guard). For handles created before the wait starts, tmail snapshots existing message IDs and resolves on the first new one.

### Read and fetch

```bash
tmail read "$addr" --unread --limit 20 | jq '.[] | {from, subject}'
tmail get "$addr" msg_01 --text
```

`read` is non-blocking and newest-first. `get` prefers HTML and returns a text rendering; `--html` gives you the raw body, `--text` forces plain.

### Send

Body comes from a flag, a file, or stdin:

```bash
echo "$body" | tmail send --to user@example.com --subject "Re: request" --html
```

```bash
tmail send \
  --to dest@example.com \
  --cc team@example.com \
  --subject "Report" \
  --body-file ./report.md \
  --attach ./report.pdf \
  --reply-to me@example.com
```

Requires SMTP credentials in config; `--from` defaults to `[smtp].from`.

### Stateless handles

Skip local state entirely, which suits ephemeral agent sandboxes:

```bash
out=$(tmail new --stateless)
h=$(echo "$out" | jq -r .handle)
tmail wait --handle "$h" --timeout 120
```

With `--handle` (or `TMAIL_HANDLE`), the `<id|address>` positional becomes optional on receive commands. If you pass both, the handle wins and the positional must match, or you get exit `7 CONFIG`.

### Clean up

```bash
tmail rm "$addr"
```

Deletes upstream, then forgets locally, and is idempotent.

## Commands

| Command | Blocking | Notes |
|---------|----------|-------|
| `new` | no | Mint an inbox; `--stateless` returns a handle |
| `ls` | no | Local inboxes, newest first |
| `read <id\|address>` | no | Message list; `--unread`, `--since`, `--limit` |
| `get <id\|address> <msgId>` | no | Full body; `--html`, `--text` |
| `wait <id\|address>` | yes | First matching new message |
| `otp <id\|address>` | yes | Extracts verification code |
| `rm <id\|address>` | no | Delete upstream + local |
| `send` | no | SMTP send; body from flag, file, or stdin |

Global flags: `--json` (default), `--pretty`, `-v`, `--handle <blob>`, `--config <path>`, `--timeout <secs>`. Command-level `--timeout` beats the global flag, which beats `[defaults].wait_timeout_secs`.

## Exit codes

Every failure prints exactly one JSON error to stdout and a human-readable line to stderr:

```json
{
  "error": {
    "code": "RATE_LIMITED",
    "message": "mail.tm is cooling down",
    "retryAfterMs": 30000
  }
}
```

| Exit | Code | Meaning |
|------|------|---------|
| 0 | — | Success |
| 1 | `GENERIC` | Unexpected / unclassified |
| 2 | `NOT_FOUND` | Unknown inbox or message id |
| 3 | `RATE_LIMITED` | Provider 429; includes `retryAfterMs` |
| 4 | `ALL_PROVIDERS_DOWN` | No inbox could be minted |
| 5 | `AUTH` | Bad or missing SMTP / provider credentials |
| 6 | `TIMEOUT` | `wait` / `otp` deadline passed |
| 7 | `CONFIG` | Malformed config or missing setting |
| 8 | `NETWORK` | Connection, DNS, or TLS failure |
| 9 | `NO_MATCH` | `otp` matched a message but found no code |

<ToolRepo href="https://github.com/raymond-UI/tmail" title="View on GitHub" />
