tmail
Link
Tags
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.
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.
#Features
- Mint — Fresh, real disposable inboxes via mail.tm
- Blocking reads —
waitandotphold until a matching message arrives, so no polling loop in your agent - Code extraction —
otprenders 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 —
--statelessreturns a portable handle instead of writing local state - Agent contract — JSON-only stdout, stable exit codes, never prompts for input
#Installation
curl --proto '=https' --tlsv1.2 -LsSf https://raw.githubusercontent.com/raymond-UI/tmail/main/install.sh | shSet TMAIL_VERSION to pin a release, or TMAIL_INSTALL_DIR to change the install location.
From source:
cargo install --git https://github.com/raymond-UI/tmailPrebuilt binaries for macOS (arm64/x86_64), Linux (x86_64/aarch64/musl), and Windows (x86_64) are on the releases page.
#Usage
#Mint an inbox
tmail new{
"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:
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){
"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:
tmail wait "$addr" --subject "Reset your password" --timeout 120Baseline 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
tmail read "$addr" --unread --limit 20 | jq '.[] | {from, subject}'
tmail get "$addr" msg_01 --textread 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:
echo "$body" | tmail send --to user@example.com --subject "Re: request" --htmltmail send \
--to dest@example.com \
--cc team@example.com \
--subject "Report" \
--body-file ./report.md \
--attach ./report.pdf \
--reply-to me@example.comRequires SMTP credentials in config; --from defaults to [smtp].from.
#Stateless handles
Skip local state entirely, which suits ephemeral agent sandboxes:
out=$(tmail new --stateless)
h=$(echo "$out" | jq -r .handle)
tmail wait --handle "$h" --timeout 120With --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
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:
{
"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 |