# Sending email (/docs/email/sending)

```ts twoslash
import { Coss } from "coss";
const coss = new Coss();
// ---cut---
const { data, error } = await coss.email.send(
  {
    from: "Acme <hello@acme.com>",
    to: ["ada@example.com", "Grace Hopper <grace@example.com>"],
    cc: "team@acme.com",
    reply_to: "support@acme.com",
    subject: "Your invoice",
    html: "<p>Your invoice is attached to your account.</p>",
    text: "Your invoice is attached to your account.",
    headers: { "X-Entity-Ref-ID": "invoice-123" },
    tags: [{ name: "invoice_id", value: "invoice-123" }],
    category: "billing",
  },
  { idempotencyKey: "invoice-123" },
);
```

Sends return HTTP `202` with `status: "queued"`. A background worker hands the email to the
provider. Follow delivery with `coss.email.get()` or the `email.sent` / `email.failed` webhooks.
Emails are kept for 45 days (see [data retention](/docs/data-retention)).

## Scheduling, rescheduling and cancellation [#scheduling-rescheduling-and-cancellation]

Pass `scheduled_at` as an ISO 8601 timestamp with a time zone offset, up to 30 days ahead.
The response is HTTP `202` with `status: "scheduled"`; the worker checks for due emails every
five seconds and rechecks the sender domain and suppression list before sending. A past timestamp
uses the normal queue. Send quotas are charged when the schedule is accepted.

```ts twoslash
import { Coss } from "coss";
const coss = new Coss();
// ---cut---
const { data: scheduled } = await coss.email.send({
  from: "hello@acme.com",
  to: "ada@example.com",
  subject: "Your reminder",
  text: "See you tomorrow!",
  scheduled_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
});
if (scheduled) {
  // Send it a day later instead…
  await coss.email.reschedule(scheduled.id, {
    scheduled_at: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(),
  });
  // …or not at all.
  await coss.email.cancel(scheduled.id);
}
```

Rescheduling moves a `scheduled` email to a new time, again at most 30 days from now, and emits
`email.rescheduled` with the new and previous times in `data.reschedule`. A time that is not in
the future sends the email within seconds; the email's current time returns it unchanged, without
an event. Only `scheduled_at` can change: to change the content, cancel and send again.
Rescheduling does not charge the send quota again.

Canceling changes the status to `canceled` and emits `email.canceled`; repeating the cancellation
returns the same email. Once dispatch starts, cancellation returns `422 email_not_cancelable` and
rescheduling `422 email_not_reschedulable` (as does rescheduling a canceled email).
Scheduling emits `email.scheduled`. These events describe the whole email.
Suspending an organization holds its scheduled emails until the suspension is lifted. Attachment
bytes remain stored while an email is scheduled and are deleted when it is sent, fails, or is canceled.

## Addresses [#addresses]

`from`, `to`, `cc`, `bcc` and `reply_to` take `"user@example.com"` or
`"Display Name <user@example.com>"`. Each recipient field takes one address or an array of up to 50.

With a live key, `from` must use a [verified domain](/docs/email/domains) of your organization.
Sandbox keys accept any `from`.

## Content [#content]

`subject` is required (unless you send a [template](#templates)). Provide `html`, `text`, or both: sending both lets mail clients pick, and
improves deliverability. When you send only `html`, coss.com adds a plain-text part generated from it
(links are followed by their URL in brackets, images by their alt text, hidden preheaders are left
out). The email keeps what you sent: its `text` stays `null`. Send your own `text` to control it.

### React Email [#react-email]

With the SDK, pass a [React Email](https://react.email) component as `react` instead of `html`. The
SDK renders it with `@react-email/render`, an optional peer dependency (`npm install
@react-email/render`), into `html` and, unless you pass your own, `text`:

```tsx
import { Coss } from "coss";
import { WelcomeEmail } from "./emails/welcome";

const coss = new Coss();

const { data, error } = await coss.email.send({
  from: "Acme <hello@acme.com>",
  to: "ada@example.com",
  subject: "Welcome to Acme",
  react: <WelcomeEmail name="Ada" />,
});
```

Rendering happens in your process, once per call, before the request: the API only ever receives
the HTML and text. `sendBatch()` renders each email's `react` the same way. `react` can't be combined
with `html`. If `@react-email/render` isn't installed or the component throws, the call returns an
`invalid_request_error` (`react_renderer_missing` or `react_render_failed`) without sending anything.
If you retry with your own idempotency key, render the same output again: a different body fails with
`idempotency_key_reused`.

### Templates [#templates]

You can also keep the content in coss.com: send a [template](/docs/email/templates) with `template_id`
and `variables` instead of `subject`, `html` and `text`. Templates can't be combined with `react`.

## Headers and tags [#headers-and-tags]

- `headers`: up to 15 custom headers. Names are case-insensitive, and reserved names such as
  `subject` are rejected. coss.com can add `List-Unsubscribe` headers itself: see
  [one-click unsubscribe](/docs/email/unsubscribe).
- `tags`: up to 50 `name`/`value` pairs (letters, digits, `_` and `-`) for your own filtering. They are
  included in webhook payloads.

## Categories [#categories]

`category` says what kind of email this is, such as `"billing"` or `"marketing"` (letters, digits,
`_` and `-`). Recipients [suppressed](/docs/email/suppressions) for that category, or for every
email, are skipped. It is stored on the email and included in webhook payloads. A category that is
also a [topic](/docs/email/topics-and-preferences) makes the email that topic's: contacts who opted
out of it are skipped.

## Streams [#streams]

`stream` is `"transactional"` (the default) or `"marketing"`. Marketing email goes out on its own
sending infrastructure and reputation, so its complaints never hold up password resets: it goes to
one recipient, needs your postal address in the marketing settings, and gets one-click unsubscribe
headers and a footer from coss.com. See [transactional and marketing email](/docs/email/streams).

## Sending in batches [#sending-in-batches]

`coss.email.sendBatch()` sends up to 100 emails in one request. Each email takes the same fields
as `coss.email.send()`, including `scheduled_at`, `attachments` and `tracking`, and they can have
different senders and recipients.

```ts twoslash
import { Coss } from "coss";
const coss = new Coss();
// ---cut---
const { data, error } = await coss.email.sendBatch(
  [
    {
      from: "Acme <notifications@acme.com>",
      to: "ada@example.com",
      subject: "Your weekly summary",
      html: "<p>Hi Ada, here is your week.</p>",
      category: "digest",
    },
    {
      from: "Acme <notifications@acme.com>",
      to: "grace@example.com",
      subject: "Your weekly summary",
      html: "<p>Hi Grace, here is your week.</p>",
      category: "digest",
    },
  ],
  { idempotencyKey: "weekly-digest-2026-10-10" },
);
```

The response is HTTP `202` with an `email_batch` whose `data` lists the emails in the order you
passed them. Each email has the batch's id as `batch_id`, and is delivered and reported on (with
webhooks) on its own. `coss.email.getBatch(id)` returns the batch again later, with its emails'
current statuses.

A batch is all or nothing. If any email is invalid, nothing is sent and the error's `issues` name
it by position, such as `emails.3.to`. The same goes for an unverified sender domain, which is
reported as `param: "emails.3.from"`. One idempotency key covers the whole batch. The batch's
recipients count against the send quota together: a batch with
more recipients than the quota allows at once is refused with no `Retry-After`, so split it into
smaller batches. The 40 MB request size limit applies to the whole batch.

## Retrying safely [#retrying-safely]

Every send carries an `Idempotency-Key`. The SDK generates one per call and reuses it across its
automatic retries; pass your own (such as `invoice-123`) to stay safe across process restarts too.
For 24 hours, repeating a request with the same key returns the original email instead of sending
again. See [idempotency](/docs/api-reference/idempotency).
