Configuration
Three ways to build a client, every option, and what it refuses before a request is sent.
Options
import os from openemail import AsyncOpenEmail, OpenEmail, init, openemail init(os.environ['OPENEMAIL_API_KEY'])openemail.me.ping() billing = OpenEmail(os.environ['BILLING_API_KEY']) pinned = OpenEmail('oe_live_...', base_url='https://api.openemail.uk') background = AsyncOpenEmail()| Entry point | What it gives you |
|---|---|
| init(...) | Configures the shared client and returns it. openemail is that client from then on, in every module, and anything you leave out is read from the environment. |
| openemail | The shared client. Used before init, it builds itself from OPENEMAIL_API_KEY and OPENEMAIL_BASE_URL on the first call. |
| OpenEmail(...) | A separate client, for a second key beside the shared one, or to build the instance your own module exports. Anything you leave out is read from the environment, as init does, and create_client is the same class under another name. |
| AsyncOpenEmail(...) | A separate asynchronous client with the same options and every method awaited. The shared openemail is synchronous, so this one you build yourself. |
| get_client() and reset_client() | get_client returns the shared client itself, building it from the environment when init has not run. reset_client forgets it, so the next use builds a new one. |
import httpxfrom openemail import init init( 'oe_live_...', base_url='https://api.openemail.uk', timeout=30, max_retries=2, http_client=httpx.Client(proxy='http://proxy.internal:3128'), headers={'X-Team': 'billing'}, user_agent='billing-service/1.4', disable_update_notice=True,)| Option | Default | Notes |
|---|---|---|
| api_key | OPENEMAIL_API_KEY | Read from the environment by init and OpenEmail. Must begin oe_live_ or oe_test_. |
| access_token | OPENEMAIL_ACCESS_TOKEN | An OAuth access token, or a function that returns one, in place of api_key. See OAuth access tokens below. |
| base_url | https://api.openemail.uk | Or OPENEMAIL_BASE_URL. A trailing slash is trimmed, and init and OpenEmail put https:// in front of a bare host, or http:// in front of a host on this machine: localhost, a 127.x.x.x address or ::1. A credential is never sent over plain http to any other host, and 0.0.0.0 or [::] raises when the client is built, because those are addresses a server listens on, not ones to send requests to. |
| timeout | 30 | In seconds, per attempt, not per call. Covers reading the body, not just the headers. 0 disables it. files.upload allows at least 600 seconds unless the call passes its own timeout. |
| max_retries | 2 | Extra attempts after the first, on calls that are safe to repeat. Set on the client, not per call. |
| http_client | a new httpx.Client | Pass your own for a proxy, your own TLS settings, a mounted transport or a test double: an httpx.Client to OpenEmail, an httpx.AsyncClient to AsyncOpenEmail. Closing the client leaves one you passed open. |
| headers | {} | Sent on every request. |
| user_agent | openemail-python/<version> | Sent on every request. |
| disable_update_notice | False | Skips the once-per-process check for a newer version on PyPI. The check only runs when output goes to a terminal, and OPENEMAIL_DISABLE_UPDATE_NOTICE turns it off too. |
What it refuses before sending
These raise ValueError, or TypeError where the table says so, before any request is sent, rather than surfacing as a confusing failure on your first send. The message says what was wrong and what to pass instead.
| Refused | Why |
|---|---|
| No key at all | Neither api_key nor OPENEMAIL_API_KEY was set, so there is nothing to authenticate with. |
| A session cookie or session token | Only oe_live_ and oe_test_ authenticate here, and the API says so too. The check is a prefix and nothing more, so a revoked key still fails on the wire. |
| A base_url that is not an http or https URL | Nothing else can be fetched, so the client refuses it when it is built rather than failing on the first request. |
| A base_url on 0.0.0.0 or [::] | An address a server listens on, not one to send requests to. The message names 127.0.0.1 or [::1] with the same port instead. |
| A credential over plain http | Refused on the call, before the request leaves, unless the server is on this machine. Anyone on the network could read it. |
| An empty or all-dots id on any method | Raised when the method is called. A path segment of dots is removed by every URL parser, so the request would reach a different endpoint. |
| An http_client of the wrong kind | A TypeError when the client is built. OpenEmail takes an httpx.Client, and AsyncOpenEmail an httpx.AsyncClient. |
| A body value JSON cannot carry | A TypeError naming its type. JSON types pass through, and a datetime, a date or a set is converted for you. |
There is no test_mode option and there will not be one. The key scheme is part of the credential rather than a hint, so mode is a property of the key. client.mode reads the prefix and decides nothing.
One client, several keys
Build the client once and share it. A fresh instance per request throws away the connection pool and the configuration for nothing, and none of the state on it is per-caller.
One client is safe to share between threads. close() or the end of a with block closes the connection pool it opened, and an http_client you passed stays open for you to close.
For the case that would otherwise force one instance per key, such as a job sending on behalf of several workspaces, pass api_key on the call. It replaces the Authorization header for that request and leaves nothing behind on the client.
from openemail.types import EmailSend workspace_key = 'oe_live_...' message: EmailSend = {'from': sender, 'to': recipient, 'subject': subject, 'text': text} client.emails.send(message) client.emails.send(message, api_key=workspace_key) client.threads.list(folder='inbox', api_key=workspace_key)client.webhooks.list(api_key=workspace_key)Every method outside temp_mail takes it as a keyword argument, beside timeout. It is checked before the request is sent, by the same rule the constructor uses, so a typo raises a ValueError naming api_key= on this call rather than a 401 about a credential you then have to go and find. A retried call keeps the key it was given.
timeout is in seconds and replaces the client’s timeout for that one call, on each of its attempts.
client.mode describes the key the client was CONSTRUCTED with and does not follow an override. Once one client serves several keys there is no single mode to report, so read it off the key you passed.
An endpoint no method wraps
client.raw.request sends a request with the client’s credential, base URL, timeout and retry policy applied, and returns the parsed JSON. It takes method, query, body, api_key and timeout. It retries a GET and sends anything else once, unless you pass repeatable=True. idempotent=True adds an Idempotency-Key, the one you pass as idempotency_key or a fresh one.
result = client.raw.request('/something-new', method='POST', body={'name': 'Invoices'})The path must begin with a single /. Anything else, such as //host/x, raises before a request is sent, and so does a path whose finished URL leaves the base URL’s origin, so the credential it carries never reaches another host.
Disposable inboxes
create_temp_mail() builds a client that carries no API key, and create_async_temp_mail() is its asynchronous twin. It creates inboxes anonymously, and each read sends the inbox token that create returned, or the newer one extend returned, either per call as inbox_token or once as create_temp_mail(inbox_token=...).
from openemail import create_temp_mail temp_mail = create_temp_mail() inbox = temp_mail.create()messages = temp_mail.list_messages(inbox['id'], inbox_token=inbox['token']) print(messages['items'], messages['expiresAt'])OAuth access tokens
Not shipped yetAn app a person connected over OAuth, such as a command line tool or an agent, holds an access token rather than an API key. Pass it as access_token, either the token itself, or a function that returns it, which on AsyncOpenEmail may be async. The function is called once for every call, and that call’s retries reuse what it returned, so renew the token inside it when it is close to expiring and the client never has to be rebuilt.
from openemail import OpenEmail client = OpenEmail(access_token=session.fresh_access_token) me = client.me.get() if me['object'] == 'oauth_token': print(me['clientId'], me['expiresAt'])| Case | What happens |
|---|---|
| api_key and access_token together, or neither | The constructor raises ValueError. With neither, the message names OPENEMAIL_API_KEY and OPENEMAIL_ACCESS_TOKEN. |
| A value that is not a token | A token is 1 to 512 characters and does not begin oe_, the check is_access_token makes. A string that fails it raises from the constructor, and a function that returns one fails the call before anything is sent. |
| OPENEMAIL_ACCESS_TOKEN | Read by init, OpenEmail and the shared openemail when you pass neither credential and OPENEMAIL_API_KEY is not set, so a key in the environment wins. |
| A function that raises | The call raises that error, unchanged, and nothing is sent. |
| A function on OpenEmail that returns an awaitable | A ValueError, since the synchronous client cannot wait for it. On AsyncOpenEmail the function may be async. |
| A per-call api_key | Replaces the token for that one request, and the function is not called. |
| mode | Always live under a token. |
| create_temp_mail() | Sends no credential, whatever the environment holds. |
| me.get() and me.ping() | For a token, get answers with object set to oauth_token, id and roleId set to None, the connected app’s clientId, and expiresAt, when the person’s approval of the app runs out. ping answers with kind set to oauth, keyId set to None and the clientId. KeyResource and PingResource are unions, so narrow on object or kind before reading clientId or expiresAt. |
A token acts for a person and reads their mail as they can, so keep it on a server like a key.
Verification codes
Not shipped yetBefore a sensitive change, such as deleting a domain or changing a webhook, the API asks an access token for the verification code the web app would ask the person for. The call raises an OpenEmailApiError whose is_step_up_required is True, and nothing was changed. Ask for a code, verify the one the person gives you, then make the call again. An API key is never asked.
from openemail import OpenEmailApiError try: client.domains.delete(domain_id)except OpenEmailApiError as error: if not error.is_step_up_required: raise challenge = client.security.begin_step_up() if challenge['method'] == 'email': prompt = f'Enter the code we emailed to {challenge.get("sentTo")}: ' else: prompt = 'Enter the code from your authenticator app, or a backup code: ' client.security.verify_step_up({'code': input(prompt)}) client.domains.delete(domain_id)| Method | What it does |
|---|---|
| security.step_up_status() | Whether the app is verified right now (elevated, elevatedUntil), how the next code is checked (method, email or totp), and minutes, the length of the window. It sends nothing, and it does not report a pause. |
| security.begin_step_up(body=None) | Opens a challenge. With email a six-digit code goes to the address the person signs in with, and sentTo shows it masked. With totp they read one from their authenticator app or use a backup code. A challenge that is still open and has tries left is reused unless you pass {'resend': True}, and a locked or expired one is replaced by a plain call. Each app may open 5 an hour and 20 in 24 hours for each person, and the next raises a 429 step_up_throttled. |
| security.verify_step_up({'code': code}) | Checks the code and unlocks sensitive changes for this app for 60 minutes, until elevatedUntil, over REST and through the MCP tools that make the same changes. After 10 wrong codes in 24 hours from this app, or 20 from all of the person’s apps together, this call and begin_step_up raise a 429 step_up_locked with a message that says when verification resumes. |
The client never asks for a code or repeats the call by itself, and neither begin_step_up nor verify_step_up is retried automatically, because a retry after a lost answer could send a second email or spend a second try. They need no scope, and an API key calling one gets 400 step_up_not_applicable. STEP_UP_ERROR_CODES names every way a verification can fail, and the API errors page says what to do about each.