---
title: "Configuration"
description: "Three ways to build a client, every option, and what it refuses before a request is sent."
url: "https://openemail.uk/docs/ruby/configuration"
area: "Ruby"
category: "Getting started"
---

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

**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
)
```

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

**per_call_key.rb**

```
message = {from: "billing@acme.com", to: "ada@example.com", 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]
```

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

```
temp_mail = OpenEmail.create_temp_mail

inbox = temp_mail.create
page = 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"
```

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

**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
```

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

- [Verification codes](https://openemail.uk/docs/api/authentication.md): Which operations ask for a code, and the limits on asking.

## 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: "billing@acme.com", to: "ada@example.com", 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.
