Node and TypeScript

The SDK, and the same call with fetch when you would rather not add a dependency.

Install

Node 18 or newer
npm install @emails.sh/sdk

Send

send.ts
import { Emailssh } from '@emails.sh/sdk';

// The key comes from https://emails.sh/dashboard/api-keys and lives in
// EMAILSSH_API_KEY in your .env. Never in a file you commit.
const mail = new Emailssh({ apiKey: process.env.EMAILSSH_API_KEY });

const { id } = await mail.send({
  from: 'Acme <hello@acme.com>',
  to: ['ada@example.com'],
  subject: 'Your receipt from Acme',
  html: '<p>Thanks for your order.</p>',
  text: 'Thanks for your order.'
});

console.log('queued', id);

The constructor takes an options object with the key in it, and falls back to EMAILSSH_API_KEY when you leave apiKey out. Every method returns a promise and throws on a refusal, with the code and the message on the error.

Handle a refusal

Errors carry a code and a sentence
import { Emailssh, EmailsshError } from '@emails.sh/sdk';

const mail = new Emailssh({ apiKey: process.env.EMAILSSH_API_KEY });

try {
  const { id } = await mail.send({
    from: 'Acme <hello@acme.com>',
    to: ['ada@example.com'],
    subject: 'Your receipt from Acme',
    html: '<p>Thanks for your order.</p>'
  });
  console.log('queued', id);
} catch (err) {
  if (err instanceof EmailsshError) {
    // err.code is the machine-readable reason, err.nextStep says what to do
    // about it in prose, and err.status is the HTTP status.
    console.error(err.code, err.message, err.nextStep);
    if (err.code === 'rate_limited') {
      // err.retryAfter is seconds. Wait it out rather than retrying harder.
    }
  } else {
    throw err;
  }
}

Without the SDK

The API is one POST. On Node 18 and newer, and on every edge runtime, fetch is built in and this is the whole integration.

lib/email.ts, no dependencies
export async function sendEmail(to: string, subject: string, html: string) {
  const res = await fetch('https://emails.sh/v1/emails', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.EMAILSSH_API_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ from: 'Acme <hello@acme.com>', to: [to], subject, html })
  });

  if (!res.ok) {
    const { error } = (await res.json()) as { error: { code: string; message: string; next?: string } };
    // next says what to do about it, in prose. Keep it: it is the part a
    // person, or an assistant, reads three months from now.
    throw new Error(`emails.sh refused the send: ${error.code}: ${error.message} ${error.next ?? ''}`);
  }

  const { id } = (await res.json()) as { id: string };
  return id;
}

Types

The package ships its own types, so the send body is checked at compile time and an unknown field is an error rather than a silently ignored key.

Typed send bodies
import type { SendEmail, SentEmail } from '@emails.sh/sdk';

export function receipt(orderId: string, to: string): SendEmail {
  return {
    from: 'Acme <hello@acme.com>',
    to: [to],
    subject: `Receipt for order ${orderId}`,
    html: `<p>Thanks for order ${orderId}.</p>`,
    tags: { template: 'receipt', order_id: orderId },
    idempotencyKey: `receipt-${orderId}`
  };
}

export function logResult(sent: SentEmail) {
  console.log(sent.id, sent.status);
}