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

# Send an email

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

## emails.send

**send-email.ts**

```
const email = await openemail.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: pdfBytes }],
  threadId: 'thread_…',
  scheduledAt: 'PT1H',
  tags: { order: '4021' },
  tracking: { opens: true, clicks: true },
})
```

`to`, `cc` and `bcc` take one recipient or many, and a lone one is wrapped for you. Each may be a bare address, `Name <addr@host>`, or `{ email, name }`.

## Parameters

- `from` (RecipientInput, required): The sender. A bare address, `Name <addr@host>`, or an object. Must be one this key may send as. There is no fallback sender, because the fallback would be postmaster@ whichever domain was added first.
- `to` (RecipientInput | RecipientInput[], required): One recipient or many; a lone one is wrapped for you. At most 50 across to, cc and bcc combined.
- `cc` (RecipientInput | RecipientInput[]): Counts toward the 50-recipient limit.
- `bcc` (RecipientInput | RecipientInput[]): Never named in the bytes anyone else receives, because one envelope is transmitted per recipient.
- `replyTo` (RecipientInput): A single address, sent as the Reply-To header.
- `subject` (string): At most 998 characters, the RFC 5322 line limit. Defaults to empty.
- `html` (string): One of html, text, draftId or template is required. HTML is what recipients see when both html and text are given.
- `text` (string): The plain-text part.
- `template` ({ id, version?, props?, slots? }): Render a stored template server-side. `version` pins; omit it 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.
- `headers` (Record<string, string>): `X-*`, `List-*`, Reply-To, Precedence, Auto-Submitted, Importance, Priority and Feedback-Id. Anything the transport sets itself is refused rather than quietly dropped.
- `attachments` (AttachmentInput[]): `{ filename, content, contentType? }`, or `{ fileId }` naming a file already in the workspace. Pass bytes for content and they are base64-encoded for you. 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` (AttachmentDeliveryMode): `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` (Date | string): A Date, an ISO-8601 instant, or a duration like `PT1H`. Up to a year out, never in the past. Cannot be combined with cancellableForSeconds.
- `cancellableForSeconds` (number): 0 to 900. An undo window on an immediate send: the composer’s undo mechanism, exposed rather than hardcoded.
- `tags` (Record<string, string>): Up to 10 labels, echoed back and filterable. Never interpreted.
- `signature` (boolean): Whether this message carries the signature of the address it is sent from, which is that address’s own signature or else the one set for All addresses. Defaults to true, because a signature belongs to the address rather than to whichever client sent the message. Set `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.
- `tracking` ({ opens?, clicks? }): Whether to add an open pixel and rewrite links for this message. On unless the workspace owner has turned tracking off for the address it is sent from or for All addresses, and either field stated here settles that one message whichever way the address is set.
- `translate` ({ to, from?, subject?, includeOriginal? }): Send it in the recipient’s language. `to` takes a code, an English name or the language’s own name; `subject` and `includeOriginal` both default to true. Resolved when the request is accepted, so a scheduled message carries the words that were approved. Refused alongside `draftId`.

## Response

- `id` (string): The send id, `msg_…`. Use it for `get`, `cancel`, `reschedule` and `getTracking`.
- `status` (EmailStatus): queued, scheduled, sending, sent, partial, cancelled or failed. Read this rather than the fact the promise resolved. `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` ('live' | 'test'): Which kind of key sent it. A test send is recorded and never transmitted.
- `from` (string): The address actually authorised and put on the wire, which is not always the one asked for.
- `subject` (string | null): As sent.
- `messageId` (string | 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 | null): The thread it landed in.
- `transport` (string | null): How the message left. Null until dispatch.
- `attempts` (number): How many times dispatch has been tried.
- `lastError` (string | null): Why the last attempt failed, verbatim.
- `scheduledAt` (string | null): ISO instant it is due to go.
- `cancellableUntil` (string | null): While now is before this, cancel still works.
- `sentAt` (string | null): ISO instant it left.
- `tags` (Record<string, string>): What you sent, echoed back.
- `source` (EmailSource): composer, api, mcp, ai or queue: which surface asked. `api` is this client.
- `createdAt` (string): ISO instant the record was written.
- `replayed` (boolean): True when an Idempotency-Key matched a send that already existed. Nothing new was sent, and this is the original message.
- `translation` (EmailTranslationResource | undefined): Present only on a message that was translated, and only where the whole stored request is carried: this response and `get`. `{ language, languageName, detectedSourceLanguage, subject, includeOriginal }`, all codes rather than 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.ts**

```
const email = await openemail.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' },
})

console.log(email.translation)
// { language: 'de', languageName: 'German', detectedSourceLanguage: 'en', subject: true, includeOriginal: 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 throw their edits away.

**preview-translation.ts**

```
const preview = await openemail.emails.translate({
  subject: 'Your September invoice',
  html: '<p>Invoice attached. Payment is due on the 14th.</p>',
  to: 'de',
})

console.log(preview.language.native, preview.detectedSourceLanguage)

const approved = await showToSomebody(preview)

await openemail.emails.send({
  from: 'billing@acme.com',
  to: 'ada@example.com',
  subject: approved.subject,
  html: approved.html,
})
```

**render-picker.ts**

```
import { LANGUAGES, isRtlLanguage, languageByCode, openemail, resolveLanguage } from '@openemail/sdk'

LANGUAGES.length // 200

const current = await openemail.languages.list()

resolveLanguage('Deutsch')?.code // 'de'
resolveLanguage('zh-TW')?.code // 'zh-Hant'
languageByCode('DE')?.native // 'Deutsch'
isRtlLanguage('ar') // true
```

The table is bundled, in picker order, so a picker can be filled before the first request. `languages.list()` resolves to the same rows off the wire as a plain array, for a caller who would rather have the current ones than the ones this version shipped with. `resolveLanguage` takes a code, an English name, an endonym or an alias (`zh-TW` is an alias of a code no longer listed), `languageByCode` matches an exact code case-insensitively, and sixteen of the rows are right to left. 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 that resolves to nothing 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, `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 is frozen against wording changes. `emails.reschedule` still moves it; changing what it says means cancelling and sending again.

## Attachments

`content` is base64 on the wire. Pass bytes and they are encoded for you.

**attachment.ts**

```
attachments: [
  { filename: 'invoice.pdf', content: pdfBytes, contentType: 'application/pdf' },
]
```

> `toBase64` is exported if you need it elsewhere. It chunks, which `btoa(String.fromCharCode(...bytes))` does not. That one fails on anything past about 100 kB, and it fails on the real file rather than the one you tested with.
