---
title: "Send an email"
description: "`emails->send`: one message, now or later."
url: "https://openemail.uk/docs/php/emails/send"
area: "PHP"
category: "Emails"
---

# Send an email

`emails->send`: one message, now or later.

## emails->send

**send_email.php**

```
$email = $client->emails->send([
    'from' => ['email' => 'billing@acme.com', 'name' => 'Acme Billing'],
    'to' => ['ada@example.com', 'Grace <grace@example.com>'],
    'cc' => 'cc@example.com',
    'bcc' => [['email' => 'archive@acme.com']],
    'replyTo' => 'replies@acme.com',
    'subject' => 'Your September invoice',
    'html' => '<p>Invoice attached.</p>',
    'text' => 'Invoice attached.',
    'headers' => ['X-Campaign' => 'invoices'],
    'attachments' => [['filename' => 'invoice.pdf', 'content' => new \SplFileInfo('invoice.pdf')]],
    'threadId' => 'CAHk7pQ2x9LmZ4-mail.example.com',
    'scheduledAt' => 'PT1H',
    'tags' => ['order' => '4021'],
    'tracking' => ['opens' => true, 'clicks' => true],
]);

echo $email['id'], ' ', $email['status'], PHP_EOL;
```

`to`, `cc` and `bcc` take one recipient or a list of them, and a lone one is wrapped for you. Each may be a bare address, `Name <addr@host>`, or an array with `email` and `name`.

The message is one array keyed by the API’s field names, which is why `replyTo` and `scheduledAt` stay camelCase, while `idempotencyKey:` and `apiKey:` are named arguments of the call and never part of the message. To change one field of a message you built earlier, spread it into a new array: `$client->emails->send([...$message, 'subject' => 'Re: your invoice'])` keeps every other field and replaces the subject.

## Parameters

- `from` (string or array, required): The sender. A bare address, `Name <addr@host>`, or an array with `email` and `name`. Must be one this key may send as, or the call throws a 403 `from_address_forbidden`. There is no fallback sender, so a send always names the address it goes out as.
- `to` (string or array, required): One recipient or a list of them, and a lone one is wrapped for you. At most 50 across `to`, `cc` and `bcc` combined, and more is a 422 `too_many_recipients`.
- `cc` (string or array): Counts toward the 50-recipient limit.
- `bcc` (string or array): Never named in the bytes anyone else receives, because one envelope is transmitted per recipient. Counts toward the 50 as well.
- `replyTo` (string or array): A single address, sent as the Reply-To header.
- `subject` (string): At most 998 characters, the RFC 5322 line limit. Defaults to empty, and an empty subject falls back to the template’s or the draft’s.
- `html` (string): One of `html`, `text`, `draftId` or `template` is required. HTML is what recipients see when both `html` and `text` are given. At most 1,000,000 characters.
- `text` (string): The plain-text part, at most 1,000,000 characters.
- `template` (array): Render a stored template server-side: an array with `id`, which takes an id or a slug, and optional `version` (an int), `props` and `slots`. `version` pins a revision. Leave it out to use whatever is published when the request is accepted. An unknown or missing prop is a 422 rather than a blank in the message.
- `draftId` (string): Send a saved draft under this envelope, as it was written. Cannot be combined with `template` or `translate`.
- `headers` (array): Header name to string value, limited to `X-*`, `List-*`, Reply-To, Precedence, Auto-Submitted, Importance, Priority and Feedback-ID. Anything the transport sets itself is refused with a 422 `reserved_header` rather than quietly dropped.
- `attachments` (array): A list, each entry an array with `filename`, `content` and an optional `contentType`, or an array with only `fileId`, naming a file already in the workspace, such as one from `files->upload`. `content` is base64: a stream from `fopen`, an `SplFileInfo` or a PSR-7 stream is read and encoded for you, and a string must be base64 already. 20 files, with inline files capped at 5 MB in total once decoded. A stored file can be larger and travels as a download link.
- `attachmentDelivery` (string): `mime`, `link` or `auto`. `auto` carries files as download links once they pass 2 MB on a domain with an active files domain, and inside the message otherwise. Left out, the mailbox setting applies, and that defaults to `auto`.
- `threadId` (string): Reply into an existing thread. The transport writes In-Reply-To and References.
- `scheduledAt` (DateTimeInterface or string): A `DateTimeInterface`, sent as an ISO 8601 instant in UTC, an ISO 8601 instant as a string, or a duration like `PT1H`. Up to a year out, never in the past. Cannot be combined with `cancellableForSeconds`. A date string with no time, such as `2027-01-01`, is read as midnight UTC on that day, so pass an instant when the hour matters.
- `cancellableForSeconds` (int): 0 to 900. An undo window on an immediate send: the composer’s undo mechanism, exposed rather than hardcoded.
- `tags` (array): Up to 10 labels, with keys of 1 to 64 letters, digits, `_` or `-` and string values up to 256 characters. Echoed back on every read and never interpreted.
- `signature` (bool): Whether this message carries the signature of the address it is sent from: that address’s own, else the catch-all’s for an address a catch-all caught, else the OpenEmail footer unless that address turned it off. Left out, an `html` body goes out exactly as written without one and a `text`-only body carries it. Set it to false for the mail a program sends on somebody’s behalf, such as a receipt, a password reset or a digest, none of which want a person’s sign-off under them. Template sends and encrypted sends never carry one.
- `tracking` (array): An array with optional `opens` and `clicks`, each a bool: whether to add an open pixel and rewrite links for this message. Off unless tracking was turned on for the address it is sent from (or the catch-all that caught it), and either key stated here settles that one message whichever way the address is set.
- `translate` (array): Send it in the recipient’s language: an array with `to` and optional `from`, `subject` and `includeOriginal`. `to` takes a code, an English name or the language’s own name, and `subject` and `includeOriginal` both default to true. Settled when the request is accepted, so a scheduled message carries the words that were approved. Refused alongside `draftId`.
- `idempotencyKey` (string): A named argument of the call rather than a field of the message. Your own key for this send, 1 to 255 characters of letters, digits, `_`, `.`, `:` or `-`. Without one the client generates a key for each call, so its own retries never send twice, and with one a send that runs again in another process replays instead of repeating.
- `apiKey` (string): A named argument too. Sends with this key instead of the client’s, for a process sending on behalf of several workspaces.

## Response

An array keyed by the API’s camelCase names, so `$email['status']` reads the status.

- `id` (string): The send id, `msg_` followed by 24 hex characters. Use it for `get`, `cancel`, `reschedule` and `getTracking`.
- `status` (string): queued, scheduled, sending, sent, partial, bounced, cancelled or failed. Read this rather than the fact that the call returned: an immediate send is dispatched inside the request and usually comes back `sent`, `partial` or `failed`, and a held one comes back `queued` or `scheduled`. `partial` is its own state: some recipients have it and cannot be un-sent, so retrying is wrong and reporting failure is a lie.
- `mode` (string): `live` or `test`: which kind of key sent it. A test send is recorded and never transmitted. It reads `sent`, with `transport` set to `test`, so assert on the response and not on an inbox.
- `from` (string): The address actually authorised and put on the wire, which is not always the one asked for.
- `subject` (string or null): As sent.
- `messageId` (string or null): The RFC 5322 Message-ID. null until the MIME exists. The sending service rewrites the header on the way out, so no bounce or delivery report carries this value. `id` is what an event comes back on.
- `threadId` (string or null): The thread it landed in.
- `transport` (string or null): How the message left. null until dispatch.
- `attempts` (int): How many times dispatch has been tried.
- `lastError` (string or null): Why the last attempt failed, verbatim.
- `scheduledAt` (string or null): The ISO 8601 instant it is due to go.
- `cancellableUntil` (string or null): While now is before this, `cancel` still works.
- `sentAt` (string or null): The ISO 8601 instant it left.
- `tags` (array): What you sent, echoed back.
- `source` (string): composer, api, mcp, ai or queue: which surface asked. `api` is this client.
- `createdAt` (string): The ISO 8601 instant the record was written.
- `replayed` (bool): True when an Idempotency-Key matched a send that already existed. Nothing new was sent, and this is the original message as it is now.
- `translation` (array): Present only on a message that was translated, and only where the whole stored request is carried: this response and `get`. It holds `language`, `languageName`, `detectedSourceLanguage`, `subject` and `includeOriginal`, with codes rather than whole language rows. A list row never has it, so its absence there says nothing either way.

## In the recipient’s language

`translate` writes the message in somebody else’s language before it goes. The body, and the subject unless you turn that off, is translated when the API accepts the request, and what came out is what goes out: a translation that could not be produced refuses the send rather than posting it in the language you wrote it in.

**translate.php**

```
$email = $client->emails->send([
    'from' => 'billing@acme.com',
    'to' => 'ada@example.com',
    'subject' => 'Your September invoice',
    'html' => '<p>Invoice attached. Payment is due on the 14th.</p>',
    'translate' => ['to' => 'de'],
]);

print_r($email['translation'] ?? []);
```

`$email['translation']` then holds `language` set to `de`, `languageName` to `German`, `detectedSourceLanguage` to `en`, and `subject` and `includeOriginal` both true.

Nobody read that before it went. `emails->translate` is the same round trip stopped one step early. Show it to a person, let them change it, then send what they approved with no `translate` on the call at all. Passing it again would translate a second time and discard their edits.

**preview_translation.php**

```
$preview = $client->emails->translate([
    'subject' => 'Your September invoice',
    'html' => '<p>Invoice attached. Payment is due on the 14th.</p>',
    'to' => 'de',
]);

echo $preview['language']['native'], PHP_EOL, $preview['subject'], PHP_EOL, $preview['html'], PHP_EOL;
echo 'Send it as it is? [y/N] ';

$answer = fgets(STDIN);

if ($answer !== false && strtolower(trim($answer)) === 'y') {
    $client->emails->send([
        'from' => 'billing@acme.com',
        'to' => 'ada@example.com',
        'subject' => $preview['subject'],
        'html' => $preview['html'],
    ]);
}
```

**languages.php**

```
use OpenEmail\Constants\Languages;
use OpenEmail\OpenEmail;

echo count(Languages::ALL), PHP_EOL;

$current = $client->languages->list();
echo count($current), PHP_EOL;

echo OpenEmail::resolveLanguage('Deutsch')['code'] ?? 'none', PHP_EOL;
echo OpenEmail::resolveLanguage('zh-TW')['code'] ?? 'none', PHP_EOL;
echo OpenEmail::languageByCode('DE')['native'] ?? 'none', PHP_EOL;
var_dump(OpenEmail::isRtlLanguage('ar'));
```

Those lines print 200, the rows this version ships with, then how many the API holds now, then `de`, `zh-Hant`, `Deutsch` and `bool(true)`. The table is bundled, in picker order, as `OpenEmail\Constants\Languages::ALL`, a list of arrays with `code`, `label`, `native`, `flag` and `rtl`, so a picker can be filled before the first request. `languages->list` returns the same rows off the wire as a plain list, for a caller who would rather have the current ones than the ones this version shipped with. `OpenEmail::resolveLanguage()` takes a code, an English name, an endonym or an alias (`zh-TW` is an alias of a code no longer listed) and returns null when nothing matches, `OpenEmail::languageByCode()` matches an exact code in any case, and `OpenEmail::isRtlLanguage()` says whether a language reads right to left, as sixteen of the rows do. Search `native`, `label` and `code` together, show `native` first, and store the code.

> `emails->translate` is not retried automatically. It spends model calls and writes nothing, so there is nothing to make idempotent and a retry after an unanswered request would only buy the same answer twice.

- A language the API cannot match is a `validation_error` on `translate.to`, before anything is sent.
- `translation_too_long` over 30,000 characters, `translation_not_configured` when the install has no AI configured, a 429 `ai_quota_exceeded` when the workspace has used today’s AI actions (it resets at midnight UTC and is not retried), `translation_failed` when the provider did not answer. None of them sends the message untranslated as a fallback.
- Works with `template`: the RENDERED output is what gets translated, so one stored body serves every language your customers read in. A template that renders a whole document keeps its doctype, its `<style>` blocks and its `@font-face` rules: only the body goes to the model and the rest is put back around it. Its `<title>` is left alone, which nothing displays anyway.
- A retry costs nothing extra. The translation is not part of the idempotency fingerprint (the request is, `translate` included), so retrying an unanswered send with the same `Idempotency-Key` replays the message that already exists rather than translating and sending a second one.
- A translated message that is queued or scheduled keeps its approved wording. `emails->reschedule` still moves it, while `emails->update` refuses new wording with a 409 `translation_locked`, so changing what it says means cancelling and sending again.

## Attachments

`content` is base64 on the wire. Hand the client something it can read and it encodes the bytes for you: a stream resource from `fopen`, an `SplFileInfo`, or a PSR-7 stream or uploaded file. A string is sent as it is, so it has to be base64 already, which is what `OpenEmail::toBase64()` makes of bytes you hold in memory.

**attachments.php**

```
use OpenEmail\OpenEmail;

$attachments = [
    ['filename' => 'invoice.pdf', 'content' => OpenEmail::toBase64(file_get_contents('invoice.pdf')), 'contentType' => 'application/pdf'],
    ['filename' => 'report.csv', 'content' => new \SplFileInfo('report.csv')],
    ['filename' => 'contacts.csv', 'content' => fopen('contacts.csv', 'rb')],
    ['fileId' => 'file_6bb640f5b99e47deb758f1f5'],
];

$client->emails->send([
    'from' => 'billing@acme.com',
    'to' => 'ada@example.com',
    'subject' => 'Your documents',
    'text' => 'All three are attached.',
    'attachments' => $attachments,
]);
```

> A string `content` that is not base64 throws `OpenEmail\Exception\InvalidArgumentException` before anything is sent. Raw bytes that happen to read as base64 would go out garbled instead, so never pass the bytes of a file as they are: wrap them in `OpenEmail::toBase64()`, or pass the file itself.

> `OpenEmail::toBase64()` is there if you need the same encoding elsewhere. It takes a string of bytes, a stream resource, an `SplFileInfo` or a PSR-7 stream and returns base64 with no line breaks.
