tmailor: Python SDK for the Tmailor Open API
The official Python SDK and CLI for the Open API of Tmailor, the temporary email service. Create receive-only temporary email addresses and read incoming messages from your backend. Use it for end-to-end sign-up tests, OTP and verification-message checks in QA pipelines, and inbox automation.
- Python 3.9+, one dependency (
httpx), fully typed (py.typed). - Equivalent sync and async APIs through
ClientandAsyncClient; async operations useawaitandasync for. - Typed errors, idempotent address creation, automatic retries, and rate-limit-aware polling.
- A
tmailorcommand-line tool for trying the API from a terminal. - API reference
Install
pip install tmailor-sdk
The package is published as tmailor-sdk; the module you import is tmailor, and the command-line tool is
tmailor.
Setup
- Sign in to Tmailor Platform, create an API key, and add the caller's public IP address to your allowlist.
- Provide the key:
export TMAILOR_API_KEY="tmk_live_..."
You can also pass it explicitly: tmailor.Client(api_key="tmk_live_..."). A client without a key can call only
health(); any other call raises tmailor.MissingKeyError (code MissingKey, no HTTP status) before sending a
request.
Quickstart
import tmailor
with tmailor.Client() as client: # reads TMAILOR_API_KEY
box = client.mailboxes.create() # random address on a shared domain
print(box.email)
sign_up_somewhere(box.email) # your code triggers an email
# `since` is exclusive (received_at > since); "- 1" also accepts a message received in the creation second.
msg = box.wait_for_message(timeout=120, since=(box.created_at or 0) - 1)
print(msg.subject, msg.otp(), msg.links())
box.delete()
Server-side only
The API accepts requests only from the IP addresses in your account allowlist, and your API key gives full access to your account. Use this SDK on servers, CI runners, or scripts you control. Never ship the API key or address tokens to browsers or mobile apps.
Client options
| Option | Default | Description |
|---|---|---|
api_key |
$TMAILOR_API_KEY |
tmk_live_<prefix>_<secret> |
base_url |
$TMAILOR_BASE_URL or https://open-api.tmailor.com |
API endpoint (see below) |
timeout |
30 |
seconds per HTTP request |
max_retries |
2 |
automatic retries (see Retries) |
max_retry_wait |
60 |
maximum Retry-After delay, in seconds, that is handled automatically |
http_client |
new httpx.Client |
bring your own (proxy, custom transport) |
allow_untrusted_base_url |
False |
required for any host other than open-api.tmailor.com |
Base URL. Every authenticated request sends your API key in the Authorization header. Address operations
also send the address token in the JSON body. The SDK therefore accepts only https:// URLs without user
information (user:pass@), a query string, or a fragment. A host other than open-api.tmailor.com (staging,
your own proxy) raises tmailor.BaseURLError (a ValueError) unless you pass allow_untrusted_base_url=True,
which states that you trust that host with your credentials. This also applies to TMAILOR_BASE_URL. Plain
http:// is accepted only for a loopback host (127.0.0.1, ::1, localhost) during local development, and
it also requires allow_untrusted_base_url=True.
Both clients are context managers (with / async with); otherwise call close() or await aclose(). The SDK
closes only HTTP clients it creates. If you pass http_client=, you remain responsible for closing it.
Addresses
box = client.mailboxes.create() # random name, shared domain
box = client.mailboxes.create(domain="mail.example.com", name="signup") # your own verified domain
box.email, box.domain, box.created_at, box.token, box.web
- Creating an address uses 1 unit of your mailbox quota (
402 QuotaExceededwhen the quota is used up). - Addresses never expire. They stay in your account until you delete them. Deleting an address does not return quota.
- Custom names are allowed only on your own domains (
client.domains()). Add and verify domains on Tmailor Platform. The system checks your domains' MX records automatically; to request an immediate check, select "Check now" on the platform. The API has no domain-check operation. - A
namethat has already been used to create an address on the tmailor.com website or mobile app cannot be used through the API.create()raisestmailor.MailboxExistsError(409) in that case; pick another name.
The token is the key to the address. Every operation on an address sends box.token in the JSON body.
Store it if you need the address later:
box = client.mailbox(saved_token) # no request is made
box.info() # loads email, message_count, current tokens
Lost a token? client.mailboxes.list() returns every address your account still owns and its current token
when available. Listing addresses is also how you recover after a token was changed on Tmailor Platform:
for box in client.mailboxes.list(limit=100): # follows next_cursor, up to 100 addresses
print(box.email, box.message_count)
page = client.mailboxes.list_page(page_size=100) # one page: .items, .next_cursor
A token and box.web may temporarily be None; an address operation without a token raises
tmailor.TmailorError with code NoToken.
Delete an address:
box.delete() # same as "Delete" on tmailor.com; all tokens stop working
The API does not rotate tokens. To replace an address's tokens, use Tmailor Platform. The old token then
produces MailboxNotFoundError, and client.mailboxes.list() returns the new token.
When it is available, box.web holds the tokens the tmailor.com website uses (accesstoken, transfertoken,
reuse_url). Anyone holding them can read the inbox on tmailor.com or, with transfertoken, take over the
address. Treat them like passwords. SDK object representations never expose complete tokens or API keys; a
client representation may show the API key's non-secret prefix.
Messages
for m in box.messages(unread=True, from_="shop", subject="confirm"): # newest first, all pages
print(m.id, m.from_.address, m.subject, m.received_at, m.seen)
page = box.messages_page(page_size=20) # .items, .next_cursor, .total; items do not include bodies
msg = box.message(m.id) # fetches .text and sanitized .html; marks the message as read
box.delete_message(msg)
deleted = box.clear() # deletes all messages and returns the count
Calling box.message(), or receiving a message from wait_for_message(), marks that message as read. It then
no longer appears with unread=True. There is no separate "mark as read" call.
Filters run on the server: unread, from_ (text in the sender name or address), subject (text in the
subject), and since (Unix time or a timezone-aware datetime; exclusive: only messages with
received_at > since, in whole seconds). limit caps how many messages the iterator yields; page_size
(1 to 100) sets the page size. Messages are kept for 24 hours. If the API ever returns a pagination cursor it
has already returned, the iterators stop with tmailor.InvalidResponseError instead of looping.
Waiting for a message
msg = box.wait_for_message(timeout=120, from_="no-reply@shop.example", subject_contains="verify")
wait_for_message returns, with its body, the first matching message that was not in the inbox when the wait
started. It first records the IDs of all current messages, without filters; from_ and subject_contains
apply only to the polls. If the message may arrive before polling starts (the usual case: you trigger the email,
then wait), pass since so that it can still match:
box = client.mailboxes.create()
trigger_email(box.email)
msg = box.wait_for_message(since=(box.created_at or 0) - 1) # server time, no clock-skew issues
since is exclusive and counts whole seconds, so since=box.created_at would miss a message received in the
same second the address was created. Subtract 1 as shown above.
The first poll is immediate. After an unsuccessful poll, the delay starts at 1 second and grows to 10 seconds.
poll_interval= sets a fixed delay with a minimum of 0.5 seconds; a value of zero or less raises ValueError.
When X-RateLimit-Remaining is low, the SDK spreads the remaining requests until the minute resets. A
429 RateLimited response during the wait is handled by sleeping for Retry-After (at least 1 second).
timeout sets the wait deadline, including for HTTP requests (with the sync Client, an in-progress request can
overrun it as described below). No sleep or retry goes past it, each request (polls and the final read of the
message) gets at most the remaining time as its HTTP timeout, and a poll scheduled after a delay starts only if
at least 1 second remains before the deadline. If nothing arrives in time, or the deadline interrupts a request, it raises
tmailor.WaitTimeoutError (also a built-in TimeoutError); timeout=0 raises it without sending a request.
httpx timeouts apply to each phase of a request (connect, send, each read), not to the whole request, so the SDK
enforces the deadline on top of them. AsyncClient cancels a request at the deadline (asyncio.wait_for), and
both clients treat a response that arrives after the deadline as WaitTimeoutError (its data is not used). With
the sync Client, a server that sends bytes very slowly can still keep one request running past the deadline, by
at most one read timeout (min(client timeout, time left)) per read. Use AsyncClient when a hard wall-clock
limit matters.
Codes and links
msg.otp() # "482913", or None when there is no confident answer
msg.links() # ["https://example.com/verify?t=..."]
The API does not extract codes or links; these helpers run locally. otp() looks for 4- to 8-digit codes
(including forms such as 123 456 and G-123456) or 4- to 8-character uppercase codes containing both letters
and digits, such as A7K29Q, preferably near words such as "code", "OTP", "verification", or "PIN". It skips candidates inside URLs,
email addresses, dates, amounts, and similar text where possible. Without a nearby keyword, it returns a value
only when there is one distinct six-digit candidate and no other eligible numeric candidate. Always handle
None. links() returns unique absolute HTTP and HTTPS URLs. The standalone helpers
tmailor.extract_otp(subject, text, html) and tmailor.extract_links(text, html) work on any string.
Account
me = client.me() # user_id, limits (quota, rate_per_min, daily_req_cap, ...), ip_allowlist, subscriptions
doms = client.domains() # your own domains + doms.shared_domains_available
usage = client.usage(from_="2026-10-01", to="2026-10-17") # daily counters
client.health() # public status: no key needed and none is sent
client.last_rate_limit # RateLimitInfo(limit, remaining, reset) of the last response
Errors
API, network, response-validation, and wait failures are subclasses of tmailor.TmailorError with code,
status, message, description, request_id, and retry_after. Invalid local arguments may instead raise
TypeError or ValueError; tmailor.BaseURLError is a ValueError. Handle errors by class or code, not by
message text. Include request_id when contacting support.
| HTTP | Class (code) |
|---|---|
| 400, 413 | BadRequestError: InvalidJsonError, InvalidFieldError, InvalidDomainError, CustomNameNotAllowedError, InvalidNameError (.reason) |
| 401 | AuthenticationError: MissingKeyError, InvalidKeyError, KeyRevokedError |
| 402 | QuotaExceededError |
| 403 | PermissionDeniedError: AccountLockedError, IpNotAllowedError |
| 404 | NotFoundError: MailboxNotFoundError, MessageNotFoundError |
| 405 | MethodNotAllowedError |
| 409 | ConflictError: MailboxExistsError (including a name created on the website or mobile app) |
| 429 | RateLimitError: RateLimitedError, DailyCapReachedError |
| 500, other 5xx | InternalServerError, unless a more specific error code applies |
| 503 | ServiceUnavailableError: NoDomainAvailableError, IdempotencyUnavailableError, CacheUnavailableError (.secret_changed); InternalServerError (InternalError, reason cache_hold_failed); a degraded health() (code HTTP503) |
| other 4xx | APIError |
| 2xx | InvalidResponseError (InvalidResponse): body not JSON, a field missing or of the wrong type, or a repeated pagination cursor. The body is never kept in the error |
| none | MissingKeyError (no API key configured), APIConnectionError, APITimeoutError (no HTTP response) |
| none | WaitTimeoutError (wait_for_message) |
try:
box = client.mailboxes.create()
except tmailor.QuotaExceededError:
...
except tmailor.IpNotAllowedError as e:
print("Add this server's IP address on platform.tmailor.com", e.request_id)
MailboxNotFoundError is raised when a token is invalid, was rotated on the platform, belongs to a deleted
address or another account, or was issued by the tmailor.com website (only tokens from the API or the platform
work). CacheUnavailableError on delete() with secret_changed=True means the address was deleted, but old
tokens may still work on tmailor.com for up to 10 minutes.
Retries
Automatic retries (up to max_retries, default 2) happen only when repeating the request is safe:
429 RateLimited: the API did not process the request, so any request is retried afterRetry-After(at least 1 second, unless it is longer thanmax_retry_wait).DailyCapReachedis never retried.500/502/503/504and network errors: retried only for reads (GET,info,messages,message) and formailboxes.create(). A failed connection (nothing sent) is retried for any request.503 CacheUnavailableondelete()is never retried.message()marks the message as read. Doing that twice has the same effect, so it is retried like a read.
mailboxes.create() always sends an Idempotency-Key (a new UUID, or yours via idempotency_key=), and every
retry reuses it, so a retry can never create a second address or use quota twice. When there is no
Retry-After, exponential backoff uses full jitter: the upper bound starts at 0.5 seconds, doubles on each
retry, and is capped at 8 seconds; the actual delay is between half the upper bound and the upper bound.
Async
import asyncio
import tmailor
async def main() -> None:
async with tmailor.AsyncClient() as client:
box = await client.mailboxes.create()
msg = await box.wait_for_message(timeout=120, since=(box.created_at or 0) - 1)
async for m in box.messages():
print(m.subject)
await box.delete()
asyncio.run(main())
Command line
The package installs the tmailor command, also available as python -m tmailor, for working with the API from
a terminal. The CLI is built on the SDK and the Python standard library.
tmailor login # asks for the API key in a hidden prompt and saves a private config file
tmailor me # account, quota, rate limit, plans
tmailor create # random address on a shared domain
tmailor wait k3x9q2m7@example.net # waits for a new message, prints subject, sender, text, OTP and links
tmailor inbox k3x9q2m7@example.net --unread
tmailor read k3x9q2m7@example.net 5d41402abc4b2a76b9719d911017c592
tmailor delete k3x9q2m7@example.net --yes
| Command | What it does |
|---|---|
me, domains, usage [--from D] [--to D] [--all-days], health |
account, own domains, daily counters, service status (health needs no key) |
create [--domain D] [--name N], list |
create an address, list your addresses |
inbox EMAIL [--unread] [--from X] [--subject Y] [--limit N] |
messages from the last 24 hours |
read EMAIL ID [--html] |
one message with its text body, OTP, and links (marks it as read) |
wait EMAIL [--from X] [--subject Y] [--timeout 120] [--any] |
wait for a new message and print it (--any: a message already in the inbox also counts) |
rm-msg EMAIL ID [-y], clear EMAIL [-y] |
delete one message, delete all messages |
delete EMAIL [-y] |
delete the address |
token EMAIL |
print the address token (the only command that shows it; keep it secret) |
shell |
interactive mode: pick an address, read or wait for messages, create or delete addresses |
login, logout [-y] |
save or remove ~/.tmailor |
- Provide an email address, and the CLI looks up its token once per run. If you have the token instead, never
put it on the command line (shell history, process list). Use
--token-stdin(tmailor inbox --token-stdin < token.txt),--token-file FILE, or--ask-token(hidden prompt). With one of these options,readandrm-msgtake only the message ID. - For commands that require authentication, the CLI reads the API key from
TMAILOR_API_KEY, then from~/.tmailor(api_key=...). If neither is available and standard input is a terminal, it uses a hidden prompt; otherwise the command fails.healthdoes not require or prompt for a key. The key is never printed. - Config and token files must be private regular files. The CLI rejects symbolic links and unsafe paths. On
platforms where these checks are not supported, use
TMAILOR_API_KEYand--token-stdin. See docs/cli-security.md for the exact rules. - These options are accepted before or after every command:
--json(the CLI result as JSON),--raw(API-shaped JSON),--base-url URL,--allow-untrusted-base-url, and--debug(traceback on errors). Most non-interactive API commands honor--jsonand--raw;token --jsonprints the email and token as JSON, whileshell,login, andlogoutalways use human-readable output. Address tokens are masked as***in every output mode excepttmailor token. - The base URL follows the same rules as the SDK:
https://only, with no user information, query string, or fragment. A host other thanopen-api.tmailor.comis refused unless--allow-untrusted-base-urlis given on that run, including when the URL comes fromTMAILOR_BASE_URLor abase_url=line in the config file.http://is allowed only for a loopback host, with the flag.login --base-urluses the URL to check the key but never saves it. rm-msg,clear,delete, andlogoutask for confirmation;-y/--yesskips it (required when there is no terminal).- By default, errors print the API code, HTTP status, message, and
request_idwithout a traceback. Use--debugto include one. Exit codes are0for success,1for an error,2for invalid arguments,3whenwaittimes out, and130when interrupted with Ctrl+C. - Terminal control characters (C0, DEL, C1) and Unicode bidirectional controls are removed from all printed
text. Argument errors, which can quote what you typed, show them as visible
\uXXXXescapes instead. In--jsonand--rawoutput, control characters are escaped rather than removed: on a terminal, the JSON is ASCII-only; when redirected to a file or pipe, it keeps other Unicode characters but writes C1, DEL, and bidirectional controls as\uXXXX.
Links
- Tmailor: free temporary email service
- Tmailor Platform: API keys, IP allowlist, domains, and plans
- API documentation
- Issue tracker
- Support
- Changelog
Development
python -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
pytest # unit, CLI, OpenAPI contract, and shared conformance scenarios (no network)
pytest -m live # real API; needs TMAILOR_API_KEY from an allowlisted IP address
ruff check . && mypy
tests/test_conformance.py replays the language-neutral scenarios in conformance/scenarios/. Every Tmailor
SDK must pass the same scenarios. The source distribution ships them too (TMAILOR_CONFORMANCE_DIR overrides the
location); if they are missing, the test run fails instead of skipping. tests/test_spec_contract.py checks
every response reader against docs/openapi.yaml of the API repository: a field the SDK requires must be
required in the spec, and a null the spec allows must be accepted. It needs PyYAML and is skipped when the
spec is not available (TMAILOR_OPENAPI_SPEC overrides the path).
License
MIT. See LICENSE.
Metadata
Release files for tmailor-sdk 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| tmailor_sdk-0.1.0.tar.gz | 101.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| tmailor_sdk-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 163.5 kB
Release files / tmailor_sdk-0.1.0.tar.gz
| Download URL | tmailor_sdk-0.1.0.tar.gz |
|---|---|
| Size | 101.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
39e7f0d0d9ef044c7f54dbe4a1a0e79965858800bdd5fb7ab4a97a240363e789
|
|
BLAKE2b-256 checksum How to use checksums |
a09c70678c0b0fcb2944c19b88a5bc257a04f277e5e24cc65283318974008e13
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 17, 2026.
Transparency logRelease files / tmailor_sdk-0.1.0-py3-none-any.whl
| Download URL | tmailor_sdk-0.1.0-py3-none-any.whl |
|---|---|
| Size | 61.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f2009d656a0d5742e4696e1e19be98f314a3bbccdc16fe2a0a92c6cb228c382d
|
|
BLAKE2b-256 checksum How to use checksums |
57be0a97fd2e0cb286775feccb156152947793bfb89cb3e8d9e61ebca1d4f5c1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 17, 2026.
Transparency log