Skip to the documentation
Ruby

Configuration

Three ways to build a client, every option, and what it refuses before a request is sent.

Options

clients.rb
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 pointWhat 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 namespaceThe 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_clientDrops 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.
options.rb
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)
OptionDefaultNotes
api_key:OPENEMAIL_API_KEYRead 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_TOKENAn 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.ukOr 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:30Seconds 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:2Extra 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.newThe 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:falseSkips 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

VariableWhat it does
OPENEMAIL_API_KEYThe key init, create_client and the shared client use when you pass neither api_key: nor access_token:.
OPENEMAIL_ACCESS_TOKENAn 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_URLThe base URL when you pass none. A bare host such as localhost:2222 gets its scheme added.
OPENEMAIL_DISABLE_UPDATE_NOTICEAny 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_proxyThe 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.

RefusedWhy
No credential at allNeither 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 togetherEvery 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 serviceOnly 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 itNothing 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 machineRaised by the call, before anything is sent. Use an https base URL.
A timeout: that is not a number of seconds, or is negativePass 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 valueChecked in headers:, user_agent: and idempotency_key:, because a line break would start a second header.
An empty or all-dots id on any methodRaised 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 HashPass 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.

per_call_key.rb
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.

raw_request.rb
ping = client.raw.request("/ping") label = client.raw.request("/labels", method: :post, body: {name: "Invoices"}) p ping[:ok], label[:id]
KeywordWhat 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.rb
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_at

create_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.

access_token.rb
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"
CaseWhat happens
api_key: and access_token: together, or neitherThe 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 tokenA 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_TOKENRead 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 raisesThe 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.modeAlways "live" under a token.
OpenEmail.create_temp_mailSends no credential, whatever the environment holds.
me.get and me.pingFor 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.

step_up.rb
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
MethodWhat it does
security.step_up_statusWhether 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_upOpens 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.

fake_adapter.rb
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.first

Printing 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 matching OpenEmail::ApiError subclass.
  • Raise Timeout::Error from call, or Net::ReadTimeout, which is one, to get an OpenEmail::NetworkError whose timeout? is true. Any other StandardError, such as Errno::ECONNREFUSED, becomes a NetworkError whose timeout? is false.
  • NameError, TypeError and ArgumentError raised 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.