Configuration
Three ways to build a client, every option, and what it refuses before a request is sent.
Options
require "openemail" OpenEmail.init(api_key: ENV.fetch("OPENEMAIL_API_KEY"))OpenEmail.me.ping pinned = OpenEmail::Client.new(api_key: ENV.fetch("OPENEMAIL_API_KEY"), base_url: "https://api.openemail.uk")quick = OpenEmail::Client.new(ENV.fetch("OPENEMAIL_API_KEY"))billing = OpenEmail.create_client(api_key: ENV.fetch("BILLING_API_KEY")) p pinned.mode, quick.mode, billing.mode| Entry point | What it gives you |
|---|---|
| OpenEmail.init(...) | Configures the shared client and returns it. OpenEmail.client is that client from then on, in every file and every thread, and anything you leave out is read from the environment. |
| OpenEmail.client, OpenEmail.emails, OpenEmail.threads and every other namespace | The shared client and shortcuts to its namespaces. Used before init, it builds itself on the first call from OPENEMAIL_API_KEY and OPENEMAIL_BASE_URL. |
| OpenEmail.reset_client | Drops the shared client, so the next call builds a fresh one from the environment. |
| OpenEmail.create_client(...) | A separate client with the same environment fallback, for a second key beside the shared one, or for a client your own code holds and passes around. |
| OpenEmail::Client.new(...) or OpenEmail::Client.new(api_key) | A separate client built from exactly what you pass. It reads no environment, so it needs api_key: or access_token:. OpenEmail.new is the same call. |
OpenEmail.init( api_key: ENV.fetch("OPENEMAIL_API_KEY"), base_url: "https://api.openemail.uk", timeout: 30, max_retries: 2, adapter: OpenEmail::NetHttpAdapter.new(max_idle: 8, keep_alive_timeout: 2), 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, create_client and the shared client. Must begin oe_live_ or oe_test_. It can also be the first argument, but not both. |
| access_token: | OPENEMAIL_ACCESS_TOKEN | An OAuth access token, or anything that responds to call and returns one. See OAuth access tokens below. Pass a key or a token, never both. |
| base_url: | https://api.openemail.uk | Or OPENEMAIL_BASE_URL. Trailing slashes are trimmed, and init and create_client 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 | Seconds per attempt, not per call. With the default adapter it covers connecting and reading the whole body, not just the headers. 0 turns it off. files.upload waits at least 600 seconds unless you pass timeout: on that call. |
| max_retries: | 2 | Extra attempts after the first, on calls that are safe to repeat. Set on the client, not per call. 0 turns retries off. |
| adapter: | OpenEmail::NetHttpAdapter.new | The HTTP layer. The default keeps up to 8 idle connections per host for 2 seconds each, and max_idle: and keep_alive_timeout: change that. Anything that responds to call(request) can take its place, which is how a test runs without a network. |
| headers: | {} | Sent on every request. |
| user_agent: | openemail-ruby/<version> | Sent on every request. |
| disable_update_notice: | false | Skips the once-per-process check for a newer version on RubyGems. The check only runs when standard output is a terminal, and OPENEMAIL_DISABLE_UPDATE_NOTICE turns it off too. |
Environment variables
| Variable | What it does |
|---|---|
| OPENEMAIL_API_KEY | The key init, create_client and the shared client use when you pass neither api_key: nor access_token:. |
| OPENEMAIL_ACCESS_TOKEN | An OAuth access token, read only when you pass neither credential and OPENEMAIL_API_KEY is not set, so a key in the environment wins. |
| OPENEMAIL_BASE_URL | The base URL when you pass none. A bare host such as localhost:2222 gets its scheme added. |
| OPENEMAIL_DISABLE_UPDATE_NOTICE | Any value that is not empty turns the update notice off, for every client in the process. |
| HTTPS_PROXY and NO_PROXY, or https_proxy and no_proxy | The proxy the default adapter connects through, and the hosts that go direct. See Proxies below. |
OpenEmail::Client.new reads none of the first three, so a client built that way never picks up a key from the environment by accident. A variable that is set but empty counts as not set.
What it refuses before sending
These raise ArgumentError from the line that had the wrong value in it, rather than surfacing as a confusing failure on your first send. The message says what was wrong and what to pass instead, and it never repeats a credential.
| Refused | Why |
|---|---|
| No credential at all | Neither api_key: nor access_token: was passed, and for init and create_client neither variable was set either, so there is nothing to authenticate with. Raised when the client is built. |
| A key and a token together | Every request carries one credential, so the client cannot tell which you meant. A key passed both as the first argument and as api_key: is refused for the same reason. |
| A session cookie, a session token or a key for another service | 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, as an OpenEmail::AuthenticationError. |
| A base_url: that is not an http or https URL, or one with a user name or password in it | Nothing else can be reached, and a credential belongs in api_key: or access_token:, not in the URL. Raised when the client is built. |
| A credential over plain http to a host that is not on this machine | Raised by the call, before anything is sent. Use an https base URL. |
| A timeout: that is not a number of seconds, or is negative | Pass seconds, or 0 for no timeout. Raised when the client is built. |
| A header name that is not a token, or a line break in a header value | Checked in headers:, user_agent: and idempotency_key:, because a line break would start a second header. |
| 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 id that is not valid UTF-8 is refused too. |
| A request body that is not a Hash | Pass keyword arguments or one Hash. Anything that responds to to_hash counts as one. |
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, "live" or "test", and decides nothing.
One client, several keys
Build the client once and share it. A fresh client per request discards its open connections for nothing, and none of the state on it is per caller. A client is frozen once it is built and is safe to use from many threads at once, so a Puma or Sidekiq process needs only one, and after a fork the child opens connections of its own.
For the case that would otherwise force one client 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.
message = {from: "[email protected]", to: "[email protected]", subject: "Your invoice", text: "Attached."}workspace_key = ENV.fetch("OPENEMAIL_API_KEY") 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 the filters on a list, and the temp_mail methods take inbox_token: instead. It is checked before the request is sent, by the same rule the client uses, so a typo raises an ArgumentError about the api_key passed to 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.
client.mode describes the key the client was BUILT 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. client.inspect shows the mode and the base URL, never the key.
Endpoints no method wraps
client.raw is the transport every method goes through. client.raw.request calls a path no method wraps yet, with the client’s credential, base URL, timeout and retry policy applied, and returns the parsed body the way a method does.
ping = client.raw.request("/ping") label = client.raw.request("/labels", method: :post, body: {name: "Invoices"}) p ping[:ok], label[:id]| Keyword | What it does |
|---|---|
| method: | :get unless you say otherwise: :post, :put, :patch or :delete. |
| query: | A Hash of query parameters. nil and empty values are left out, an Array or a Set is joined with commas, and a Time is sent as an ISO 8601 instant. |
| body: | A Hash, sent as JSON. |
| raw: and content_type: | Bytes to send as they are, as a binary String, an IO or a Pathname, with application/octet-stream unless you name a type. |
| accept: and binary: | An accept: other than JSON returns the body as text, and binary: true returns it as a binary String. |
| idempotent: and idempotency_key: | idempotent: true attaches an Idempotency-Key, generated unless you pass your own. |
| repeatable: | Whether a failure is retried. Only a GET is, unless you pass repeatable: true. |
| api_key: and timeout: | The same per-call key, and a timeout in seconds for this call alone. |
The path must begin with a single /, and a path whose finished URL would leave the base URL’s origin raises ArgumentError before anything is sent, so the credential never reaches another host.
Disposable inboxes
OpenEmail.create_temp_mail builds a client for disposable inboxes that carries no API key and reads none from the environment. 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 OpenEmail.create_temp_mail(inbox_token:).
temp_mail = OpenEmail.create_temp_mail inbox = temp_mail.createpage = temp_mail.list_messages(inbox[:id], inbox_token: inbox[:token]) p page.items.size, page.expires_atcreate_temp_mail takes base_url:, adapter:, max_retries:, timeout:, user_agent:, headers: and disable_update_notice: like any client, and reads OPENEMAIL_BASE_URL when you pass no base URL.
OAuth access tokens
An 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 anything that responds to call and returns it, such as a lambda or a Method. It 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.
tokens = {current: "token-from-your-oauth-flow"} oauth_client = OpenEmail::Client.new(access_token: -> { tokens.fetch(:current) }) me = oauth_client.me.get puts me[:clientId], me[:expiresAt] if me[:object] == "oauth_token"| Case | What happens |
|---|---|
| api_key: and access_token: together, or neither | The client raises ArgumentError when it is built. 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 OpenEmail.access_token? makes. A String that fails it raises when the client is built, and a callable that returns one raises ArgumentError from the call before anything is sent. |
| OPENEMAIL_ACCESS_TOKEN | Read by init, create_client and the shared client when you pass neither credential and OPENEMAIL_API_KEY is not set, so a key in the environment wins. |
| A callable that raises | The call raises that error, unchanged, and nothing is sent. |
| A per-call api_key: | Replaces the token for that one request, and the callable is not called. |
| client.mode | Always "live" under a token. |
| OpenEmail.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 nil, 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 nil and the clientId. Check object or kind before you read id or keyId. |
A token acts for a person and reads their mail as they can, so keep it on a server like a key.
Verification codes
Before 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 OpenEmail::PermissionError, a 403 whose 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.
domain_id = "b3e1f0a4-6c2d-4e8a-9f17-2d5c8a0b4e6f" begin client.domains.delete(domain_id)rescue OpenEmail::ApiError => error raise unless error.step_up_required? challenge = client.security.begin_step_up if challenge[:method] == "email" puts "Enter the code we emailed to #{challenge[:sentTo]}" else puts "Enter the code from your authenticator app, or a backup code" end client.security.verify_step_up(code: $stdin.gets.to_s.strip) client.domains.delete(domain_id)end| 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 | 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:) | 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 none of the three methods 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 a 400 step_up_not_applicable. OpenEmail::STEP_UP_ERROR_CODES names every way a verification can fail, and the API errors page says what to do about each.
The update notice
When a newer version of the gem is on RubyGems, the client says so once per process, on standard error, as a line such as ℹ openemail 0.0.2 is available, you are on 0.0.1. followed by the gem’s page. The check runs when the first client is built, in a background thread with a two second timeout, only when standard output is a terminal, and a failure to reach RubyGems is ignored.
The check goes through the client’s adapter, so a test adapter can see a request to RubyGems when the tests run in a terminal. Build test clients with disable_update_notice: true, or set OPENEMAIL_DISABLE_UPDATE_NOTICE.
Proxies
The default adapter finds its proxy with Ruby’s own URI#find_proxy, so it follows the same rules as the rest of the standard library: https_proxy or HTTPS_PROXY names the proxy, and no_proxy or NO_PROXY lists the hosts that go direct. A user name and password in the proxy URL are sent to the proxy, and a server on this machine is never reached through one.
Connections use TLS 1.2 or later and check the server’s certificate, so a proxy that inspects TLS needs its certificate authority trusted by OpenSSL on the machine.
Testing without a network
adapter: replaces the HTTP layer. It is anything that responds to call(request), a lambda included, and returns an OpenEmail::HttpResponse with status, headers and body. The request is an OpenEmail::HttpRequest with method, url, headers, body and timeout, so a test can check exactly what would have gone out.
requests = [] adapter = lambda do |request| requests << request OpenEmail::HttpResponse.new( status: 200, headers: {"content-type" => "application/json"}, body: JSON.generate({id: "msg_test", status: "sent", replayed: false}) )end test_client = OpenEmail::Client.new(api_key: "oe_test_fake", adapter:, max_retries: 0, disable_update_notice: true) sent = test_client.emails.send(from: "[email protected]", to: "[email protected]", subject: "Hi", text: "Hello") p sent[:status], requests.first.method, requests.first.url, requests.first.headers["Idempotency-Key"]p requests.firstPrinting a request shows its Authorization header as [redacted], so a test log never holds the key.
- Return a status outside 2xx with the API’s error envelope as the body, such as
{"error": {"type": "validation_error", "code": "invalid_parameter", "message": "..."}}, to get the matchingOpenEmail::ApiErrorsubclass. - Raise
Timeout::Errorfromcall, orNet::ReadTimeout, which is one, to get anOpenEmail::NetworkErrorwhosetimeout?is true. Any other StandardError, such asErrno::ECONNREFUSED, becomes aNetworkErrorwhosetimeout?is false. NameError,TypeErrorandArgumentErrorraised inside the adapter count as bugs in it. They are raised unchanged and never retried.
Build a test client with max_retries: 0 when you script failures. Otherwise a retryable status or a network failure on a call that is safe to repeat is tried three times, with real sleeps in between.