Skip to the documentation
PHP

Configuration

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

Options

clients.php
use OpenEmail\OpenEmail; $client = new OpenEmail(); OpenEmail::init(timeout: 10);OpenEmail::getClient()->me->ping(); $billing = new OpenEmail(apiKey: (string) getenv('OPENEMAIL_BILLING_API_KEY')); echo $client->mode, ' ', $billing->mode, PHP_EOL;
Entry pointWhat it gives you
new OpenEmail(...)A client built from the named arguments you pass. Anything you leave out is read from the environment: the key from OPENEMAIL_API_KEY or a token from OPENEMAIL_ACCESS_TOKEN when you pass no credential, and the base URL from OPENEMAIL_BASE_URL when you pass none.
OpenEmail::createClient(...)The same client as new OpenEmail(...), for code that would rather call a factory.
OpenEmail::init(...)Builds a client, keeps it as the shared client and returns it. It takes the same named arguments.
OpenEmail::getClient()The shared client, from anywhere in the process. Called before init, it builds one from the environment on the first call.
OpenEmail::resetClient()Drops the shared client, so the next getClient() builds a fresh one, which is what a test wants between cases.

Casting getenv() to a string is deliberate. A variable that is not set becomes an empty key, which the client refuses with a message naming the variable it needs, where null would quietly fall back to OPENEMAIL_API_KEY.

options.php
use OpenEmail\Http\CurlHttpClient;use OpenEmail\OpenEmail; $client = new OpenEmail(    apiKey: (string) getenv('OPENEMAIL_API_KEY'),    baseUrl: 'https://api.openemail.uk',    httpClient: new CurlHttpClient(),    maxRetries: 2,    timeout: 30,    userAgent: 'billing-service/1.4',    headers: ['X-Team' => 'billing'],    disableUpdateNotice: true,);
OptionDefaultNotes
apiKey:OPENEMAIL_API_KEYMust begin oe_live_ or oe_test_. Read from the environment only when you pass neither apiKey: nor accessToken:.
accessToken:OPENEMAIL_ACCESS_TOKENAn OAuth access token, or a callable that returns one. See OAuth access tokens below. Pass a key or a token, never both.
baseUrl:https://api.openemail.ukOr OPENEMAIL_BASE_URL. Trailing slashes are trimmed, and https:// goes 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 [::] is refused 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, covering connecting and reading the whole response. 0 turns it off. files->upload waits at least 600 seconds unless you pass timeout: on that call.
maxRetries:2Extra attempts after the first, on calls that are safe to repeat. Set on the client, not per call. 0 turns retries off.
httpClient:CurlHttpClientThe HTTP layer: anything that implements OpenEmail\Http\HttpClient, such as Psr18HttpClient around Guzzle or Symfony HttpClient, or a fake in a test. The HTTP clients page covers each.
headers:[]Sent on every request.
userAgent:openemail-php/<version>Sent on every request.
disableUpdateNotice:falseSkips the once-per-process check for a newer version on Packagist. The check only runs on the command line when standard output is a terminal, and OPENEMAIL_DISABLE_UPDATE_NOTICE turns it off too.

Environment variables

VariableWhat it does
OPENEMAIL_API_KEYThe key a client uses when you pass neither apiKey: nor accessToken:.
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 cURL connects through, and the hosts that go direct. See Proxies and TLS below.

Each variable is read with getenv() first, then from $_SERVER and $_ENV, so a value your framework loaded from a .env file counts too. A variable that is set but empty counts as not set.

What it refuses before sending

These throw OpenEmail\Exception\InvalidArgumentException 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 apiKey: nor accessToken: was passed and neither variable was set, so there is nothing to authenticate with. Thrown when the client is built.
A key and a token togetherEvery request carries one credential, so the client cannot tell which you meant.
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 AuthenticationException.
A baseUrl: 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 apiKey: or accessToken:, not in the URL. Thrown when the client is built.
A credential over plain http to a host that is not on this machineThrown by the call, before anything is sent. Use an https base URL.
A negative timeout:Pass seconds, or 0 for no timeout. Thrown when the client is built, or by the call for a timeout passed to one call.
A header name that is not a token, or a line break or other control character in a header valueChecked in headers:, userAgent: and idempotencyKey:, because a line break would start a second header. Spaces, tabs and line breaks around a value are trimmed first, as fetch trims them, so a key read from a file that ends in a newline still works.
An empty or all-dots id on any methodThrown 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.
Attachment content that is not base64A string is always read as base64, so raw bytes in one would be sent as garbage. Encode them with OpenEmail::toBase64(), or pass an SplFileInfo, a stream or a PSR-7 stream and the client encodes it.

The class extends PHP’s own InvalidArgumentException, so code that already catches that keeps working, and it implements OpenEmail\Exception\OpenEmailException like every other exception the package throws. A value of the wrong type, such as a number where a string id goes, is a TypeError from PHP itself, because every method declares its types.

There is no testMode: 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 connection for nothing, and none of the state on it is per caller.

For the case that would otherwise force one client per key, such as a job sending on behalf of several workspaces, pass apiKey: on the call. It replaces the Authorization header for that request and leaves nothing behind on the client.

per_call_key.php
$message = [    'from' => '[email protected]',    'to' => '[email protected]',    'subject' => 'Your invoice',    'text' => 'Attached.',];$workspaceKey = (string) getenv('OPENEMAIL_API_KEY'); $client->emails->send($message); $client->emails->send($message, apiKey: $workspaceKey); $client->threads->list(folder: 'inbox', apiKey: $workspaceKey);$client->webhooks->list(apiKey: $workspaceKey);

Every method outside tempMail takes it as its last named argument, after the filters on a list, and the tempMail methods take inboxToken: instead. It is checked before the request is sent, by the same rule the client uses, so a typo throws an InvalidArgumentException about the apiKey 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. var_dump($client) shows the mode and the base URL, never the key, and every parameter that takes a credential is marked #[\SensitiveParameter], so a stack trace prints a placeholder in its place.

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 decoded body the way a method does.

raw_request.php
$ping = $client->raw->request('/ping'); $label = $client->raw->request('/labels', method: 'POST', body: ['name' => 'Invoices']); var_dump($ping, $label);
Named argumentWhat it does
method:GET unless you say otherwise: POST, PUT, PATCH or DELETE.
query:An array of query parameters. null and empty values are left out, a list is joined with commas, and a DateTimeInterface is sent as an ISO 8601 instant in UTC.
body:An array, sent as JSON.
raw: and contentType:Bytes to send as they are, as a string, a stream resource, an SplFileInfo or a PSR-7 stream, 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 string of bytes.
idempotent: and idempotencyKey: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.
anonymous:true sends no credential at all.
apiKey:, inboxToken: and timeout:The same per-call credentials, 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 throws InvalidArgumentException before anything is sent, so the credential never reaches another host.

Disposable inboxes

OpenEmail::createTempMail() 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 inboxToken: or once as OpenEmail::createTempMail(inboxToken: ...).

temp_mail.php
use OpenEmail\OpenEmail; $tempMail = OpenEmail::createTempMail(); $inbox = $tempMail->create();$page = $tempMail->listMessages($inbox['id'], inboxToken: $inbox['token']); echo count($page->items), ' ', $page->expiresAt, PHP_EOL;

OpenEmail::createTempMail() takes baseUrl:, httpClient:, maxRetries:, timeout:, userAgent:, headers: and disableUpdateNotice: 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 accessToken:, either the token itself or a callable that returns it, such as a closure or a first-class callable. The callable runs 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.php
use OpenEmail\OpenEmail; $tokens = ['current' => 'token-from-your-oauth-flow']; $oauthClient = new OpenEmail(accessToken: fn(): string => $tokens['current']); $me = $oauthClient->me->get(); if ($me['object'] === 'oauth_token') {    echo $me['clientId'], ' ', $me['expiresAt'], PHP_EOL;}
CaseWhat happens
apiKey: and accessToken: together, or neitherThe client throws InvalidArgumentException 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::isAccessToken() makes. A string that fails it is refused when the client is built, and a callable that returns one makes the call throw InvalidArgumentException before anything is sent.
OPENEMAIL_ACCESS_TOKENRead when you pass neither credential and OPENEMAIL_API_KEY is not set, so a key in the environment wins.
A callable that throwsThe call throws that exception, unchanged, and nothing is sent.
A per-call apiKey:Replaces the token for that one request, and the callable is not called.
$client->modeAlways live under a token.
OpenEmail::createTempMail()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 null, 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 null 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 throws a PermissionException, a 403 whose isStepUpRequired() 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.php
use OpenEmail\Exception\ApiException; $domainId = 'b3e1f0a4-6c2d-4e8a-9f17-2d5c8a0b4e6f'; try {    $client->domains->delete($domainId);} catch (ApiException $error) {    if (!$error->isStepUpRequired()) {        throw $error;    }     $challenge = $client->security->beginStepUp();     if ($challenge['method'] === 'email') {        echo 'Enter the code we emailed to ', $challenge['sentTo'], PHP_EOL;    } else {        echo 'Enter the code from your authenticator app, or a backup code', PHP_EOL;    }     $client->security->verifyStepUp(['code' => trim((string) fgets(STDIN))]);    $client->domains->delete($domainId);}
MethodWhat it does
security->stepUpStatus()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->beginStepUp()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 throws a 429 step_up_throttled.
security->verifyStepUp(['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 beginStepUp throw 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\Constants\StepUpErrorCodes 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 package is on Packagist, the client says so once per process, on standard error, as a line such as ℹ openemail/sdk 0.0.2 is available, you are on 0.0.1. followed by the package’s page. The check runs only on the command line, when standard output is a terminal, and never under a web server. It starts when the first client is built and runs beside your requests, and at the end of the script it waits for whatever is left of a two second budget. A failure to reach Packagist is ignored.

The check makes its own request with cURL, outside the client’s httpClient:, so a fake HTTP client in a test never sees it. Pass disableUpdateNotice: true or set OPENEMAIL_DISABLE_UPDATE_NOTICE to turn it off.

Proxies and TLS

The default CurlHttpClient leaves proxies to cURL, which reads https_proxy or HTTPS_PROXY for the proxy and no_proxy or NO_PROXY for the hosts that go direct. Pass proxy: to name one in code instead. A user name and password in the proxy URL are sent to the proxy.

curl_options.php
use OpenEmail\Http\CurlHttpClient;use OpenEmail\OpenEmail; $client = new OpenEmail(httpClient: new CurlHttpClient(    caBundle: '/etc/ssl/certs/corporate-ca.pem',    proxy: 'http://proxy.internal:3128',    curlOptions: [CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4],));

Connections use TLS 1.2 or later and check the server’s certificate and host name, and redirects are never followed. caBundle: names the certificate authorities to trust, for a proxy that inspects TLS. curlOptions: sets any other cURL option, but the settings a request needs always win: the URL and its port, the method, the headers, the body and redirects staying off. CURLOPT_REQUEST_TARGET is refused, and an option cURL does not accept throws an InvalidArgumentException that names it.