> ## Documentation Index
> Fetch the complete documentation index at: https://docs.dairo.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Send an email

> Send email from your own domain in one API call — text, HTML, React, or a stored template, plus attachments, scheduling, and safe retries.

Sending an email is one call. Pick the inbox it comes from, name the recipients,
give it a body — Dairo authenticates the mail, delivers it, and records what
happens next. Everything else on this page is optional.

## The basic send

Call `POST /v1/messages` (scope `messages:send`) with an `inboxId` — the verified
inbox the mail comes from — at least one recipient in `to`, and one body. That is
a complete send.

<CodeGroup>
  ```bash title="cURL" theme={null}
  curl -X POST https://api.dairo.app/v1/messages \
    -H "Authorization: Bearer $DAIRO_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "inboxId": "inbox_7c9e6679",
      "to": "ada@example.com",
      "subject": "Welcome to Acme",
      "html": "<p>Thanks for signing up.</p>"
    }'
  ```

  ```ts title="TypeScript" theme={null}
  const result = await dairo.messages.send({
    inboxId: "inbox_7c9e6679",
    to: "ada@example.com",
    subject: "Welcome to Acme",
    html: "<p>Thanks for signing up.</p>",
  });
  console.log(result.id, result.status); // msg_… sent
  ```

  ```python title="Python" theme={null}
  result = dairo.messages.send(
      inbox_id="inbox_7c9e6679",
      to="ada@example.com",
      subject="Welcome to Acme",
      html="<p>Thanks for signing up.</p>",
  )
  print(result.id, result.status)  # msg_… sent
  ```

  ```bash title="CLI" theme={null}
  dairo send \
    --inbox-id inbox_7c9e6679 \
    --to ada@example.com \
    --subject "Welcome to Acme" \
    --html "<p>Thanks for signing up.</p>"
  ```

  ```text title="MCP" theme={null}
  Tool: send_message   (scope messages:send)
  Args: { "inboxId": "inbox_7c9e6679", "to": ["ada@example.com"], "subject": "Welcome to Acme", "html": "<p>Thanks for signing up.</p>" }
  ```
</CodeGroup>

An immediate send is performed synchronously, so the response is the definitive
outcome — you never get an acceptance that fails later on something knowable at
submit time.

```json theme={null}
{
  "id": "msg_3fa85f64",
  "status": "sent",
  "channel": "email",
  "providerMessageId": "msg_pm_9f8c2a1b4e7d",
  "warnings": []
}
```

Keep the `id` — it keys delivery tracking and the webhook events Dairo emits as
the mail progresses.

| `status`    | When you get it                                                            |
| ----------- | -------------------------------------------------------------------------- |
| `sent`      | The email left Dairo. This is the normal result of an immediate send.      |
| `scheduled` | The request carried a future `sendAt`; `scheduledAt` echoes the fire time. |
| `queued`    | Returned only by audience broadcasts, which fan out in the background.     |
| `failed`    | The send could not be completed; `error` carries the reason.               |

Dairo picks the transport automatically: external addresses go out as email, and
when every recipient is another Dairo inbox the message is delivered directly
with `channel: "a2a"` — still returning `sent`. Pass `channel` to force one or
the other; see [Channels](/concepts/channels).

<Note>
  Provide exactly one body — `text`, `html`, `react`, or `template`. Two body
  fields in one request is a `400`, and so is a send with no body at all unless it
  carries at least one attachment (an attachment-only email is valid). `text` and
  `html` together may total at most 1 MB.
</Note>

## Request fields

| Field                                  | Description                                                                                                                                  |
| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `inboxId`                              | **Required.** The verified inbox to send from.                                                                                               |
| `to`                                   | **Required.** One address or an array. A [contact reference](/sending/sending-email#send-to-a-contact) works anywhere an address does.       |
| `cc`, `bcc`                            | More recipients, single or array.                                                                                                            |
| `subject`                              | Subject line. Defaults to empty.                                                                                                             |
| `text` / `html` / `react` / `template` | The body — exactly one.                                                                                                                      |
| `contactId`                            | Send to a stored contact: its primary handle is added to `to`, so no raw address is needed.                                                  |
| `attachments`                          | Up to 10 files, inline or by storage reference.                                                                                              |
| `replyTo`                              | A single address, set verbatim as the `Reply-To` header.                                                                                     |
| `headers`                              | Up to 25 custom MIME headers as `{ "name": "value" }`. Protected header names, malformed names, and values containing CR or LF are rejected. |
| `tags`                                 | Up to 25 name/value pairs recorded on the send. Names and values are 1–256 characters of ASCII letters, digits, `_`, and `-`.                |
| `idempotencyKey`                       | De-duplicates retries — up to 128 characters. The `Idempotency-Key` header does the same.                                                    |
| `sendAt`                               | A future time, up to 30 days ahead, to schedule delivery.                                                                                    |
| `channel`                              | `email` or `a2a`; omit to route automatically. `a2a` requires every recipient to be a Dairo inbox and cannot combine with `sendAt`.          |
| `ignoreComplaints`                     | Deliberately email recipients who previously reported your mail as spam. Default `false`.                                                    |
| `dryRun`                               | Validate the send without performing it. Returns a preview; nothing is created, sent, or billed. Default `false`.                            |

A send takes at most 50 recipients across `to`, `cc`, and `bcc` combined.

## Multiple recipients, CC and BCC

`to`, `cc`, and `bcc` each accept a single address or an array.

<CodeGroup>
  ```ts title="TypeScript" theme={null}
  await dairo.messages.send({
    inboxId: "inbox_7c9e6679",
    to: ["ada@example.com", "grace@example.com"],
    cc: "manager@example.com",
    bcc: ["audit@acme.com"],
    subject: "Quarterly report",
    text: "Attached.",
  });
  ```

  ```python title="Python" theme={null}
  dairo.messages.send(
      inbox_id="inbox_7c9e6679",
      to=["ada@example.com", "grace@example.com"],
      cc="manager@example.com",
      bcc=["audit@acme.com"],
      subject="Quarterly report",
      text="Attached.",
  )
  ```
</CodeGroup>

## Send to a contact

You don't have to know a recipient's raw address. If you keep an address book of
[contacts](/contacts/contacts), reference one and Dairo resolves it against the
sending inbox's channel — for an email inbox, to the contact's primary email
address. Raw addresses in the same list pass through untouched, so you can mix
them freely.

| Reference                     | Resolves to                                  |
| ----------------------------- | -------------------------------------------- |
| `@me`                         | The contact bound to your own account.       |
| `@alias`                      | The contact with that alias.                 |
| `contact:<id>`                | The contact with that id.                    |
| `contactId` (top-level field) | The contact's primary handle, added to `to`. |

A contact with no handle for the sending inbox's channel is a `422` — resolution
never guesses an address. The resolved contact is stamped on the outbound
message, so the send joins that contact's cross-channel history.

<CodeGroup>
  ```bash title="cURL" theme={null}
  # A saved contact by alias, plus a raw address
  curl -X POST https://api.dairo.app/v1/messages \
    -H "Authorization: Bearer $DAIRO_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "inboxId": "inbox_7c9e6679",
      "to": ["@ada", "grace@example.com"],
      "subject": "Your account is ready",
      "text": "You are all set — sign in any time."
    }'

  # Or address the send with a top-level contactId — no `to` needed
  curl -X POST https://api.dairo.app/v1/messages \
    -H "Authorization: Bearer $DAIRO_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "inboxId": "inbox_7c9e6679",
      "contactId": "contact:3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "subject": "Your account is ready",
      "text": "You are all set — sign in any time."
    }'
  ```

  ```ts title="TypeScript" theme={null}
  await dairo.messages.send({
    inboxId: "inbox_7c9e6679",
    to: ["@ada", "grace@example.com"],
    subject: "Your account is ready",
    text: "You are all set — sign in any time.",
  });
  ```

  ```python title="Python" theme={null}
  dairo.messages.send(
      inbox_id="inbox_7c9e6679",
      to=["@ada", "grace@example.com"],
      subject="Your account is ready",
      text="You are all set — sign in any time.",
  )
  ```

  ```text title="MCP" theme={null}
  Tool: send_message   (scope messages:send)
  Args: { "inboxId": "inbox_7c9e6679", "to": ["@ada"], "subject": "Your account is ready", "text": "You are all set — sign in any time." }
  ```
</CodeGroup>

## Send a React email

Pass a `react` object and Dairo renders your
[React Email](https://react.email) component into HTML — no build step or render
service on your side. Send the component source plus any props; imports are
limited to React Email components. `source` may be up to 64 KiB and `props` up
to 32 KiB of JSON.

<CodeGroup>
  ```ts title="TypeScript" theme={null}
  await dairo.messages.send({
    inboxId: "inbox_7c9e6679",
    to: "ada@example.com",
    subject: "Your receipt",
    react: {
      source: `export default function Receipt({ total }) {
        return <p>Your total is {total}.</p>;
      }`,
      props: { total: "$42.00" },
    },
  });
  ```

  ```python title="Python" theme={null}
  dairo.messages.send(
      inbox_id="inbox_7c9e6679",
      to="ada@example.com",
      subject="Your receipt",
      react={
          "source": "export default function Receipt({ total }) { return <p>Your total is {total}.</p>; }",
          "props": {"total": "$42.00"},
      },
  )
  ```
</CodeGroup>

To reuse a design across many sends, save it once as a
[template](/templates/templates) and send it by `id` instead.

## Attach files

Each attachment is an object, and you attach it one of two ways — never both:

* **Inline bytes** — base64-encode the file into `contentBase64`. Requires a
  `filename`; `contentType` is recommended. Best for small files.
* **Storage reference** — pass the `objectId` of a Dairo storage object you own
  (the id a bucket upload returns on finalize). Dairo fetches the bytes and
  attaches them natively, deriving `filename` and `contentType` from the object;
  override either in the same attachment object.

Limits: up to **10 attachments** per send. Inline bytes are capped at
**8 MiB per file and 8 MiB total** across all inline attachments — they ride the
API request itself. A storage reference attaches up to **24 MiB**. Over these
limits the send returns `413`; Dairo never silently drops an attachment or edits
your body.

<CodeGroup>
  ```ts title="TypeScript" theme={null}
  import { readFileSync } from "node:fs";

  await dairo.messages.send({
    inboxId: "inbox_7c9e6679",
    to: "ada@example.com",
    subject: "Your invoice",
    text: "Invoice attached.",
    attachments: [
      {
        filename: "invoice.pdf",
        contentType: "application/pdf",
        contentBase64: readFileSync("invoice.pdf").toString("base64"),
      },
    ],
  });
  ```

  ```python title="Python" theme={null}
  import base64

  with open("invoice.pdf", "rb") as f:
      content_base64 = base64.b64encode(f.read()).decode()

  dairo.messages.send(
      inbox_id="inbox_7c9e6679",
      to="ada@example.com",
      subject="Your invoice",
      text="Invoice attached.",
      attachments=[
          {
              "filename": "invoice.pdf",
              "contentType": "application/pdf",
              "contentBase64": content_base64,
          }
      ],
  )
  ```

  ```bash title="Storage reference" theme={null}
  # Attach a stored object by id — no base64, up to 24 MiB
  curl -X POST https://api.dairo.app/v1/messages \
    -H "Authorization: Bearer $DAIRO_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "inboxId": "inbox_7c9e6679",
      "to": "ada@example.com",
      "subject": "Your invoice",
      "text": "Invoice attached.",
      "attachments": [{ "objectId": "obj_9f8c2a1b4e7d" }]
    }'
  ```
</CodeGroup>

For a file larger than 24 MiB, share a link instead: create a
[share link](/storage/share-links) for the stored object and place it in your
`text` or `html` yourself. Dairo never rewrites your message or inserts links on
its own — an attachment with `delivery: "link"` is rejected for exactly this
reason.

## Test a send without sending it

Set `dryRun: true` and Dairo validates the request exactly as it would a real
send — the body rules, the recipients, the sending inbox, complaint suppression,
your attachments — resolves which channel it would go out on, and then stops.

```bash title="cURL" theme={null}
curl -X POST https://api.dairo.app/v1/messages \
  -H "Authorization: Bearer $DAIRO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "inboxId": "inbox_7c9e6679",
    "to": "ada@example.com",
    "subject": "Order confirmation",
    "text": "Order #1001 confirmed.",
    "dryRun": true
  }'
```

```json title="Response" theme={null}
{
  "object": "message",
  "status": "preview",
  "dryRun": true,
  "persisted": false,
  "from": "orders@yourdomain.com",
  "channel": "email",
  "recipientCount": 1,
  "agentHint": "Dry run: this message was validated but NOT created, queued, sent, or billed."
}
```

Nothing is written, queued, handed to the provider, metered, or billed, and
there is no message id — a preview has no row to fetch and will never emit
delivery events. A bad payload still fails the way it normally would: a dry run
validates, it does not wave anything through.

<Warning>
  `dryRun` is a per-**request** control. The `dairo_test_` / `dairo_live_` prefix
  on an API key is a cosmetic label with no effect on delivery — a `dairo_test_`
  key sends real mail to real people and is billed identically. If you want to
  send nothing, say so on the request.
</Warning>

## Prevent duplicate sends

Pass an `idempotencyKey` (or the `Idempotency-Key` header) and a retried request
won't send a duplicate — Dairo returns the result of the original send instead.

<CodeGroup>
  ```bash title="cURL" theme={null}
  curl -X POST https://api.dairo.app/v1/messages \
    -H "Authorization: Bearer $DAIRO_API_KEY" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: order-1001-confirmation" \
    -d '{
      "inboxId": "inbox_7c9e6679",
      "to": "ada@example.com",
      "subject": "Order confirmation",
      "text": "Order #1001 confirmed."
    }'
  ```

  ```ts title="TypeScript" theme={null}
  await dairo.messages.send(
    {
      inboxId: "inbox_7c9e6679",
      to: "ada@example.com",
      subject: "Order confirmation",
      text: "Order #1001 confirmed.",
    },
    { idempotencyKey: "order-1001-confirmation" },
  );
  ```
</CodeGroup>

<Tip>
  Build the key from the thing you're emailing about — an order ID, an event ID, a
  row's primary key — so the same logical email always carries the same key. See
  [Retries & idempotency](/concepts/idempotency).
</Tip>

## Schedule for later

Add `sendAt` to any send and Dairo holds the email until then — useful for
reminders, drip sequences, and time-zoned announcements.

`sendAt` is an RFC 3339 timestamp with an explicit timezone offset (for example
`2026-07-01T09:00:00-04:00`, or `…Z` for UTC — a bare local time is a `400`). It
must be in the future, at most 30 days ahead.

<CodeGroup>
  ```bash title="cURL" theme={null}
  curl -X POST https://api.dairo.app/v1/messages \
    -H "Authorization: Bearer $DAIRO_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "inboxId": "inbox_7c9e6679",
      "to": "ada@example.com",
      "subject": "Your trial ends tomorrow",
      "text": "Reminder: your trial ends in 24 hours.",
      "sendAt": "2026-07-01T09:00:00-04:00"
    }'
  ```

  ```ts title="TypeScript" theme={null}
  const result = await dairo.messages.send({
    inboxId: "inbox_7c9e6679",
    to: "ada@example.com",
    subject: "Your trial ends tomorrow",
    text: "Reminder: your trial ends in 24 hours.",
    sendAt: "2026-07-01T09:00:00-04:00",
  });
  console.log(result.status, result.scheduledAt); // scheduled  <fire time>
  ```

  ```python title="Python" theme={null}
  result = dairo.messages.send(
      inbox_id="inbox_7c9e6679",
      to="ada@example.com",
      subject="Your trial ends tomorrow",
      text="Reminder: your trial ends in 24 hours.",
      send_at="2026-07-01T09:00:00-04:00",
  )
  print(result.status, result.scheduled_at)  # scheduled  <fire time>
  ```

  ```bash title="CLI" theme={null}
  dairo send \
    --inbox-id inbox_7c9e6679 \
    --to ada@example.com \
    --subject "Your trial ends tomorrow" \
    --text "Reminder: your trial ends in 24 hours." \
    --send-at 2026-07-01T09:00:00-04:00
  ```
</CodeGroup>

The response comes back with `status: "scheduled"` and the exact `scheduledAt`
time the email will go out, normalized to UTC.

```json theme={null}
{
  "id": "msg_3fa85f64",
  "channel": "email",
  "status": "scheduled",
  "scheduledAt": "2026-07-01T13:00:00Z",
  "warnings": []
}
```

At fire time Dairo re-checks the send — inbox, domain, complaint suppression,
and quota — and either delivers it or marks it `failed`. A recipient who
reported spam after you scheduled the email is still protected.

## Cancel a scheduled send

While an email is still `scheduled`, `POST /v1/messages/{messageId}/cancel`
stops it for good and sets its status to `canceled`. A send that is no longer
scheduled — already queued, sent, failed, or canceled — returns a `409`.

<CodeGroup>
  ```bash title="cURL" theme={null}
  curl -X POST https://api.dairo.app/v1/messages/msg_3fa85f64/cancel \
    -H "Authorization: Bearer $DAIRO_API_KEY"
  ```

  ```ts title="TypeScript" theme={null}
  const canceled = await dairo.messages.cancel("msg_3fa85f64");
  console.log(canceled.status); // canceled
  ```

  ```python title="Python" theme={null}
  canceled = dairo.messages.cancel("msg_3fa85f64")
  print(canceled.status)  # canceled
  ```

  ```bash title="CLI" theme={null}
  dairo outbound cancel msg_3fa85f64
  ```
</CodeGroup>

## Recipients who reported spam

Dairo remembers every recipient who marked your mail as spam and refuses to
email them again: a send to such a recipient fails with a `400` naming the
address. Set `ignoreComplaints: true` to override deliberately — the send then
proceeds and the response's `warnings` array lists each affected recipient:

```json theme={null}
{
  "id": "msg_3fa85f64",
  "status": "sent",
  "channel": "email",
  "warnings": [
    {
      "recipient": "ada@example.com",
      "reason": "complaint",
      "message": "Recipient previously complained; do not contact again unless you are sure."
    }
  ]
}
```

<Warning>
  Emailing people who reported you as spam damages your domain's reputation for
  every inbox you send from, and can push future mail into spam folders. Override
  only when you are certain — and note that the override never applies to
  scheduled sends: a complaint on file at fire time marks the send `failed`. See
  [Land in the inbox](/webhooks/deliverability).
</Warning>

## List and read your sends

List outbound messages (most recent first) with `GET /v1/messages` filtered on
`direction: "outbound"`. To follow one send, use the `id` the send call
returned: fetch it for its current status, or list its per-recipient delivery
events.

<CodeGroup>
  ```ts title="TypeScript" theme={null}
  const sends = await dairo.messages.list({ direction: "outbound", limit: 25 });
  const detail = await dairo.messages.get("msg_3fa85f64");
  const { events } = await dairo.messages.listEvents("msg_3fa85f64");
  ```

  ```python title="Python" theme={null}
  sends = dairo.messages.list(direction="outbound", limit=25)
  detail = dairo.messages.get("msg_3fa85f64")
  events = dairo.messages.list_events("msg_3fa85f64")
  ```
</CodeGroup>

## Next steps

* [Track delivery](/sending/outbound-tracking) — statuses, delivery events, bounces, and complaints for every send.
* [Webhooks](/webhooks/webhooks) — get delivery events pushed to your app instead of polling.
* [Messages & threads](/receiving/messages-and-threads) — handle the replies that come back.
* [Audiences & broadcasts](/audiences/audiences) — send one email to a whole list.
