# Send from TypeScript

`@lessly/mail` is a typed client for the Lessly Mail sending API. It does over a few method calls what you would otherwise do with hand-written `fetch` calls: it builds the request URL for your product, sends your API key, unwraps the response, turns a failed request into a typed error, and retries the failures that are worth retrying.

## What the SDK covers

| Covered by the SDK | Over the HTTP API only |
|---|---|
| Sending a single email, reading that email back, managing templates | Batches, scheduled sends, domains, webhooks, suppressions |

For anything in the right-hand column, see [send a message](/ship/mail/sending).

## Install it

1. **Point the `@lessly` scope at Lessly's registry.** The package is published there rather than to the public npm registry, so put the registry URL in an `.npmrc` next to your `package.json`.

   ```ini
   @lessly:registry=<the registry URL for your organization>
   ```

2. **Install the package.** It requires **Node.js 20 or newer**, and ships both an ES module and a CommonJS build, so `import` and `require` both work. TypeScript type declarations are included; there is no separate `@types` package.

   ```bash
   npm install @lessly/mail
   ```

React is not installed for you. You only need it if you intend to send a React Email component.

## Create a client

```ts

const mail = new Mail(process.env.MAIL_API_KEY!, {
  productId: 'p-123',
});
```

The first argument is a Mail API key — the `lmk_` secret you were shown once when the key was created. The key is sent as the `X-Api-Key` header on every request. A key with the `sending_access` scope is enough to send email; managing templates needs `full_access`. See [create and rotate a sending key](/ship/mail/api-keys).

| Option | Required | Default | What it does |
|---|---|---|---|
| `productId` | Yes | — | Your product. Becomes a path segment in every request URL. |
| `baseUrl` | No | `https://public.lessly.dev` | The host the client talks to. Trailing slashes are stripped. |
| `timeout` | No | `30000` | Per-request timeout in milliseconds. |
| `retry` | No | See below | `{ maxAttempts?, initialDelay?, maxDelay? }`. |

Every request goes to `{baseUrl}/{productId}/mail/...`, so the client above sends to `https://public.lessly.dev/p-123/mail/emails`.

> The constructor throws a plain `Error` if the API key is an empty string (`apiKey must not be empty`) or if `productId` is missing or empty (`productId must not be empty`). Both are programming mistakes, not API failures, so they are not `MailError`s.

A client exposes two resources: `mail.emails` and `mail.templates`.

## Send an email

```ts
const { id } = await mail.emails.send({
  from: 'Acme <hi@send.acme.com>',
  to: 'user@example.com',
  subject: 'Welcome',
  html: '<p>Hello</p>',
});
```

`send` resolves to `{ id }` — the identifier of the accepted message. It does not wait for delivery; read the message back or subscribe to a webhook to learn what happened to it.

These fields are accepted on any send:

| Field | Type | Notes |
|---|---|---|
| `from` | `string` | Required. An address at a verified domain of yours. |
| `to` | `string \| string[]` | Required. |
| `cc` | `string \| string[]` | |
| `bcc` | `string \| string[]` | |
| `reply_to` | `string \| string[]` | |
| `headers` | `Record<string, string>` | Custom headers. |
| `tags` | `{ name: string; value: string }[]` | Your own name/value pairs for grouping messages. |

On top of those, the content of the message is given in exactly one of three forms. The types enforce this: an object that mixes two of them does not compile.

**Inline HTML and text.** At least one of `html` and `text` is required, and so is `subject`:

```ts
await mail.emails.send({
  from: 'Acme <hi@send.acme.com>',
  to: 'user@example.com',
  subject: 'Your receipt',
  html: '<p>Thanks for your order.</p>',
  text: 'Thanks for your order.',
});
```

**A published template.** Give the template id and the values for its variables. There is no `subject` here — the subject is part of the template. A draft or unknown template id is rejected as a `NotFoundError`.

```ts
await mail.emails.send({
  from: 'Acme <hi@send.acme.com>',
  to: 'user@example.com',
  template: { id: 'tmpl_1', variables: { FIRST_NAME: 'Sam' } },
});
```

**A React Email component.** Covered below.

### Do not send the same message twice

Pass an idempotency key as the second argument to `send`, and it goes out as the `Idempotency-Key` request header:

```ts
await mail.emails.send(
  { from: 'Acme <hi@send.acme.com>', to: 'user@example.com', subject: 'Welcome', html: '<p>Hi</p>' },
  { idempotencyKey: `welcome-${userId}` },
);
```

This matters because the client retries some failures for you: without a key, a request that failed on the way back could be sent a second time.

## Read an email back

```ts
const email = await mail.emails.get(id);

console.log(email.status, email.last_event.type);
```

`get` resolves to an `Email`:

| Field | Type |
|---|---|
| `id` | `string` |
| `from` | `string` |
| `to`, `cc`, `bcc` | `string[]` |
| `subject` | `string` |
| `status` | `string` |
| `provider_message_id` | `string \| null` |
| `created_at` | `string` |
| `last_event` | `{ type: string; created_at: string \| null }` |

Calling `get('')` throws a plain `Error` (`id must not be empty`) without making a request.

## Send a React Email component

You can hand `send` a React component instead of an HTML string:

```tsx

await mail.emails.send({
  from: 'Acme <hi@send.acme.com>',
  to: 'user@example.com',
  subject: 'Welcome',
  react: <WelcomeEmail name="Sam" />,
});
```

The component is rendered **in your own process**, before the request is made, into an HTML body and a plain-text body. Those two strings are what the request carries; the component itself never leaves your machine, so nothing about your component tree is sent to Mail. `subject` is required in this form, as it is for inline content.

React is an optional peer dependency of the package. If you use `react`, install `react` and `react-dom` yourself — version 18 or 19 of each. If you never pass `react`, you do not need them.

```bash
npm install react react-dom
```

## Manage templates

Template methods live on `mail.templates`. They need an API key with the `full_access` scope.

| Method | Returns | What it does |
|---|---|---|
| `create(params)` | `Template` | Creates a template. It starts as a `draft`. |
| `get(id)` | `Template` | Reads one template. |
| `list()` | `Template[]` | Lists every template in the product. |
| `update(id, params)` | `Template` | Changes a draft. Every field is optional. |
| `publish(id)` | `Template` | Makes a draft sendable. |
| `delete(id)` | `void` | Deletes the template. |

`create` takes `{ name, subject, html, text?, variables? }`; `update` takes the same fields, all optional. A variable is `{ name, type: 'string' | 'number' | 'boolean', optional?, fallback? }`. A returned `Template` has `id`, `name`, `subject`, `html`, `text?`, `variables`, `status` (`'draft'` or `'published'`), `created_at` and `updated_at`.

```ts
const template = await mail.templates.create({
  name: 'Welcome',
  subject: 'Welcome, {{FIRST_NAME}}',
  html: '<p>Hello {{FIRST_NAME}}</p>',
  variables: [{ name: 'FIRST_NAME', type: 'string' }],
});

await mail.templates.publish(template.id);
```

Two rules are worth knowing before you build a flow around this. Only drafts can be edited — `update` on a published template fails with a `ConflictError`, and to change a published template you create a new one. And `publish` is safe to repeat: publishing an already published template returns it unchanged rather than failing. See [send from a template](/ship/mail/templates).

Each of `get`, `update`, `publish` and `delete` throws a plain `Error` (`id must not be empty`) on an empty id, without making a request. An id that does not exist gives a `NotFoundError`.

## Handle errors

Every failed request throws an instance of `MailError`. It carries:

| Property | Type | |
|---|---|---|
| `message` | `string` | The message from the response body, or `Request failed with status <n>` if the body had none. |
| `statusCode` | `number` | The HTTP status. `0` for a network failure. |
| `errorType` | `string` | The machine-readable error name from the body, or `Error`. |
| `retryAfter` | `number \| undefined` | Seconds, from the `Retry-After` header. Only set on a rate limit. |

The subclass tells you what went wrong without inspecting the status code:

| Class | Raised on |
|---|---|
| `ValidationError` | 400 — the request body is malformed. |
| `AuthenticationError` | 401 — the API key is missing, unknown or revoked. |
| `ForbiddenError` | 403 — the key is not allowed to do this. |
| `NotFoundError` | 404 — no such email, template, or no published template with that id. |
| `ConflictError` | 409 — for example, editing a published template. |
| `UnprocessableEntityError` | 422 — the request is well formed but cannot be carried out, such as an invalid recipient. |
| `RateLimitError` | 429 — you are sending too fast. Read `retryAfter`. |
| `InternalError` | 500 and every other unmapped status. |
| `NetworkError` | The request never completed: the connection failed, or it hit the client's `timeout`. |

Catch the specific class you care about and let the rest bubble up:

```ts

try {
  await mail.emails.send({
    from: 'Acme <hi@send.acme.com>',
    to: 'user@example.com',
    subject: 'Welcome',
    html: '<p>Hi</p>',
  });
} catch (error) {
  if (error instanceof RateLimitError) {
    console.warn('rate limited, retry after', error.retryAfter, 'seconds');
  } else if (error instanceof ValidationError) {
    console.error('bad request:', error.message);
  } else if (error instanceof MailError) {
    console.error(error.errorType, error.statusCode, error.message);
  } else {
    throw error;
  }
}
```

`MailError` is the base class of all of them, including `NetworkError`, so `error instanceof MailError` is the catch-all for anything that came from a request.

## Retries

The client retries a request on its own when the failure looks temporary: **HTTP 429 and any status of 500 or above**. Nothing else is retried — a 400, a 401 or a 404 will not get better on a second attempt, and neither a timeout nor a connection failure is retried either.

By default a request is attempted **3 times** in total. Between attempts the client waits 500 ms, then 1000 ms, doubling each time up to a ceiling of 5000 ms. When a rate limit response carries a `Retry-After` header, that value is used instead of the computed wait. If the last attempt still fails, its error is thrown.

| Option | Default | Meaning |
|---|---|---|
| `maxAttempts` | `3` | Total attempts, including the first. `1` disables retrying. |
| `initialDelay` | `500` | Milliseconds waited before the second attempt. |
| `maxDelay` | `5000` | Ceiling on the wait, in milliseconds. |

```ts
const mail = new Mail(process.env.MAIL_API_KEY!, {
  productId: 'p-123',
  retry: { maxAttempts: 5, initialDelay: 1_000, maxDelay: 10_000 },
});
```

Retries are why sends are worth making idempotent: pass an idempotency key, as above, and a retried request cannot become a second message.

## Next steps

- [Send a message](/ship/mail/sending): batches, scheduling and the limits the SDK does not cover.
- [Send from a template](/ship/mail/templates): variables, escaping and what publishing freezes.
- [Create and rotate a sending key](/ship/mail/api-keys): the scope the client needs.
- [Receive delivery events](/ship/mail/webhooks): learn what happened after `send` resolved.
