Skip to content

Mail

Send from TypeScript

Install @lessly/mail, create a client for your product, and send, read and template email with typed errors and retries.

@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 SDKOver the HTTP API only
Sending a single email, reading that email back, managing templatesBatches, scheduled sends, domains, webhooks, suppressions

For anything in the right-hand column, see send a message.

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.

    @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.

    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

import { Mail } from '@lessly/mail';

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.

OptionRequiredDefaultWhat it does
productIdYesYour product. Becomes a path segment in every request URL.
baseUrlNohttps://public.lessly.devThe host the client talks to. Trailing slashes are stripped.
timeoutNo30000Per-request timeout in milliseconds.
retryNoSee 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 MailErrors.

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

Send an email

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:

FieldTypeNotes
fromstringRequired. An address at a verified domain of yours.
tostring | string[]Required.
ccstring | string[]
bccstring | string[]
reply_tostring | string[]
headersRecord<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:

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.

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:

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

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

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

get resolves to an Email:

FieldType
idstring
fromstring
to, cc, bccstring[]
subjectstring
statusstring
provider_message_idstring | null
created_atstring
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:

import { WelcomeEmail } from './emails/Welcome';

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.

npm install react react-dom

Manage templates

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

MethodReturnsWhat it does
create(params)TemplateCreates a template. It starts as a draft.
get(id)TemplateReads one template.
list()Template[]Lists every template in the product.
update(id, params)TemplateChanges a draft. Every field is optional.
publish(id)TemplateMakes a draft sendable.
delete(id)voidDeletes 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.

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.

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:

PropertyType
messagestringThe message from the response body, or Request failed with status <n> if the body had none.
statusCodenumberThe HTTP status. 0 for a network failure.
errorTypestringThe machine-readable error name from the body, or Error.
retryAfternumber | undefinedSeconds, from the Retry-After header. Only set on a rate limit.

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

ClassRaised on
ValidationError400 — the request body is malformed.
AuthenticationError401 — the API key is missing, unknown or revoked.
ForbiddenError403 — the key is not allowed to do this.
NotFoundError404 — no such email, template, or no published template with that id.
ConflictError409 — for example, editing a published template.
UnprocessableEntityError422 — the request is well formed but cannot be carried out, such as an invalid recipient.
RateLimitError429 — you are sending too fast. Read retryAfter.
InternalError500 and every other unmapped status.
NetworkErrorThe 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:

import { Mail, RateLimitError, ValidationError, MailError } from '@lessly/mail';

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.

OptionDefaultMeaning
maxAttempts3Total attempts, including the first. 1 disables retrying.
initialDelay500Milliseconds waited before the second attempt.
maxDelay5000Ceiling on the wait, in milliseconds.
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

Was this page helpful?
Esc

Start typing to search the docs.

navigateselect