npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@marlinjai/mail-sdk

v0.8.1

Published

Typed client for the Lumitra Mail v1 API: Node and edge runtimes, retries with idempotency, cursor pagination, webhook verification

Readme

@marlinjai/mail-sdk

The typed client for the Lumitra Mail v1 Application Programming Interface (API). Every request and response shape comes from @marlinjai/mail-contract (workspace dependency): this package only adds the Hypertext Transfer Protocol (HTTP) mechanics on top (authentication, retries, idempotency, cursor pagination). Nothing here redefines a shape the contract already owns.

Runs in Node 20+ (Node 18/19 need --experimental-global-webcrypto for crypto.randomUUID, which the SDK uses to mint idempotency keys) and on every major edge runtime: only fetch, FormData, AbortController and globalThis.crypto are used, no node: built-ins.

Install

pnpm add @marlinjai/mail-sdk

Quick start: a server-side client in a Next.js app (ŌPUNTIA's Studio)

ŌPUNTIA's admin keeps its own people and pushes recipients per mailing; the mail service is its mail infrastructure. A workspace application programming interface (API) key is created once in the mail service's dashboard and stored as a server-only secret (never shipped to the browser).

// lib/mail-client.ts (server-only module)
import { createMailClient } from '@marlinjai/mail-sdk';

export const mail = createMailClient({
  baseUrl: process.env.MAIL_SERVICE_URL!, // e.g. https://mail.lumitra.co
  apiKey: process.env.MAIL_API_KEY!, // a workspace key, scope "send" is enough to mail people
});
// app/actions/send-programme-update.ts ("use server")
import { mail } from '@/lib/mail-client';

export async function sendProgrammeUpdate(document: unknown, recipients: { email: string; external_id: string }[]) {
  const mailing = await mail.mailings.create({
    subject: 'This week at ŌPUNTIA',
    topic: 'programme-updates',
    provider_id: process.env.MAIL_PROVIDER_ID!,
    document: document as never, // the editor's TemplateDocument
  });

  await mail.mailings.addRecipients(mailing.id, { recipients });
  await mail.mailings.send(mailing.id);
  return mailing;
}

Every mutating call carries its own Idempotency-Key automatically, reused across retries, so calling sendProgrammeUpdate again after a network blip never double-sends: a retried mailings.send with the same key returns the first response instead of starting a second send.

Paginating a list

for await (const contact of mail.paginate('contacts.list', { query: { topic: 'programme-updates' } })) {
  console.log(contact.email);
}

paginate only accepts operations whose response is a cursor page ({ data, next_cursor }); passing billing.plans or contactProperties.list, which return a bare { data } array, is a compile error, not a runtime surprise.

Anything the friendly methods do not cover

Every namespaced method (mail.mailings.*, mail.contacts.*, and so on) is a thin wrapper over mail.request(operationId, { params, query, body }), which accepts any operation id from @marlinjai/mail-contract's route table and is typed from the same source. Reach for it directly for an operation this package has not wrapped yet, or to pass a raw AbortSignal.

const workspace = await mail.request('workspace.get');

Recipe: a confirmation letter after a form

The pattern for any one-to-one mail your backend sends in answer to something a person did (a contact form, a signup for updates, a booking request): a letter, which is a mailing with no topic and one recipient (docs/public/mail-contract.md, "Letters"). It takes three calls, each with an idempotency key derived from your own key for the submission, so a retried request sends one letter, never two.

import { createMailClient, MailApiError } from '@marlinjai/mail-sdk';

const mail = createMailClient({ baseUrl: process.env.MAIL_SERVICE_URL!, apiKey: process.env.MAIL_SERVICE_API_KEY! });

/**
 * submissionKey: a stable id you already store for the form submission.
 * locale: the language the person used on your site ('fr', 'de', ...).
 * reason: the key the form's drop-down sent ('venue', 'press', ...), never a sentence.
 */
export async function sendConfirmation(submissionKey: string, email: string, name: string, locale: string, reason: string) {
  const mailing = await mail.mailings.create(
    {
      template_id: process.env.MAIL_TEMPLATE_CONTACT_CONFIRMATION!,
      provider_id: process.env.MAIL_PROVIDER_ID!,
      // No `topic`: that is what makes it a letter.
      send_locale: locale,
      metadata: { kind: 'contact_confirmation', submission: submissionKey },
    },
    { idempotencyKey: `${submissionKey}:create` },
  );
  await mail.mailings.addRecipients(
    mailing.id,
    { recipients: [{ email, merge: { first_name: name, reason } }] },
    { idempotencyKey: `${submissionKey}:recipients` },
  );
  await mail.mailings.send(mailing.id, { idempotencyKey: `${submissionKey}:send` });
  return mailing.id;
}

What each piece does, and why:

  • No contacts.upsert first. Adding a recipient by email creates the contact when there is none (subscribed to nothing, since a letter has no topic) and never changes one that exists. A form usually takes an address without proof that the person owns it, so a submission should not be able to rename someone, change their language or subscribe them to anything; leaving the upsert out guarantees that. Per-letter values (the name as typed, a gathering or product name) go in the recipient's merge, which fills the template's {{first_name}} and other fields for this letter only.

  • The language. send_locale picks the template's ready version in that language, and the template's main language when there is none; subject and preheader come from the same version. Pass the language the person used on your site, validated against the ones you support.

  • Choices, not prose. When the letter's wording depends on what the person did (which reason they picked, whether they filled an optional field, whether a name is known), the template holds the sentences as insertions, in every language, and you send only the value that chooses: reason: 'venue', about: true, the name (docs/public/mail-contract.md, "Insertions"). Never compute a sentence in your code and pass it as a merge value: it would be in one language, unreviewed, inside a letter in another. Offer the choices as a fixed list (a drop-down, never free text) built from the template's keys, and check your data against them before sending:

    import { insertionInputs } from '@marlinjai/mail-sdk';
    
    const template = await mail.templates.get(process.env.MAIL_TEMPLATE_CONTACT_CONFIRMATION!);
    const accepted = insertionInputs(template.insertions).reason?.keys ?? [];
    // ['venue', 'expert', 'partnership', 'press']: anything else gets the "otherwise" text.

    A value no choice takes still sends (with the otherwise text) and is recorded as a miss (matched: false in insertions on message.sent, and in mailings.languages), so a drift between your form and the template shows up instead of passing silently.

  • The provider (provider_id, required) is the sending account the workspace has set up: find its id with mail.providers.list() or on the dashboard's Providers page, and keep it in configuration next to the template ids. A letter uses the same provider as any other mailing.

  • Subject and preheader come from the template. mailings.create refuses a subject that differs from the template's (subject_from_template); pass one only for a template that has none.

  • Idempotency. A replay with the same key and the same body within 24 hours answers with the first response and changes nothing, so the create returns the first mailing's id and the send does not send again. Derive the keys from your submission key (not from a random value per attempt), keep the body of each call identical across attempts, and retry within the 24 hours. The same key with a different body is idempotency_key_reused: that is a bug in the caller, not something to retry. Refusals are kept too: every answer below 500 (a validation_failed, an unknown_provider) is stored against its key and replayed, so retrying the same key after fixing the cause gets the old refusal back. When a retry changes the body (a provider id looked up again, say), give that call a key of its own, for example ${submissionKey}:create:${providerId}: a refused call created nothing, so a new key cannot duplicate a letter. Only a 5xx releases the key.

  • Consent. A letter reaches anyone not blocked on every topic (an all-topics unsubscribe, a hard bounce, a complaint). A blocked person's recipient ends skipped with skip_reason: "suppressed". The letter still needs {{unsubscribe_url}} in the template; it opens the hosted page with every topic and "unsubscribe from everything" first.

  • Knowing what happened. send answers once the letter is queued, not delivered. The message.sent and message.failed webhooks carry mailing_id and your metadata as mailing_metadata, so the receiver can file the outcome against the submission without a lookup (see A webhook receiver).

  • Bound it yourself. Idempotency stops a retry, not someone submitting the form again and again with fresh submissions to flood one inbox. Count the letters you sent to an address recently and stop at a small number.

  • Never let the letter fail the form. Send it after the submission is safely stored, catch MailApiError and network errors, record them, and tell the person their submission worked.

Testing against a fake: implement the same replay rule (same key and body, same answer), addRecipients idempotent on the address, and at most one recipient on a letter, or the tests prove less than the real service does.

Declaring what a template receives

A template's editor cannot see your code, so on its own it cannot say that reason is your contact form's "Reason" drop-down, which values it sends, or that first_name may be blank. Your app can say so: declare each template's inputs from the same constants your forms use, on every deploy. The editor then shows where every value comes from, names an insertion's rows by your labels ("I manage a venue", not venue), and starts its example values from your examples (docs/public/mail-contract.md, "Template inputs").

import { createMailClient, templateInputProblems, type TemplateInputsDeclaration } from '@marlinjai/mail-sdk';

const mail = createMailClient({ baseUrl: process.env.MAIL_SERVICE_URL!, apiKey: process.env.MAIL_SERVICE_API_KEY! });

// Built from the form's own constants, never typed twice.
const contactReasons = ['general', 'venue', 'press'] as const;
const reasonLabels: Record<(typeof contactReasons)[number], string> = {
  general: 'General question',
  venue: 'I manage a venue',
  press: 'Press & Media',
};

export const contactInputs: TemplateInputsDeclaration['inputs'] = {
  first_name: {
    kind: 'text',
    label: 'First name',
    source: { kind: 'form_field', form: 'Contact form', field: 'Name' },
    optional: true,
    example: 'Anna',
  },
  reason: {
    kind: 'one_of',
    label: 'Contact reason',
    source: {
      kind: 'form_field',
      form: 'Contact form',
      field: 'Reason (dropdown)',
      note: 'Also set by /contact?reason=; anything unknown is sent as general.',
    },
    values: contactReasons.map((value) => ({ value, label: reasonLabels[value] })),
    example: 'venue',
  },
};

/** Run after each deploy (or at server start), once per template the app sends from. */
export async function declareInputs(commit: string) {
  const result = await mail.templates.declareInputs(
    process.env.MAIL_TEMPLATE_CONTACT_CONFIRMATION!,
    { app: 'ŌPUNTIA website', app_version: commit, inputs: contactInputs },
    { idempotencyKey: `inputs:contact:${commit}` },
  );
  return result.changed; // false when this commit declared exactly this already
}

What each piece does, and why:

  • The kinds decide how the editor names an insertion's rows: boolean (Yes and No; false and absent are both No), text and url (Has a value and Is empty), one_of (a row per value with your label, then Anything else and Not given). values[].value is exactly what your form sends, in an insertion choice key's syntax (lowercase letters, digits, - and _).
  • Where it comes from: form_field names the form and the field as a person sees them on your site; derived says in a note how your code works it out ("the joined gathering's title in the visitor's language", "whether the optional field was answered; the text is never sent").
  • Only what you send. email and unsubscribe_url are filled by the service and refused (input_reserved); first_name and last_name are yours to declare, since your merge values come first.
  • Keep it true in your tests. templateInputProblems(inputs) runs the checks the service runs (a value listed twice, an example that is not a value). Compare the declared names with the keys your merge builder returns, so a renamed field fails your build, not a letter.
  • On every deploy. The same declaration again answers changed: false and writes nothing; a new app_version alone moves declared_at, which is how the editor tells a stale sync ("declared at commit 4d59e60, 3 days ago"). It creates no template version, so it never collides with someone editing the template. Do not let a failed declaration fail the deploy: log it (to Sentry, say) and carry on; the previous declaration stays in place.
  • Who may declare. An API key with send or full scope. The dashboard cannot (forbidden, details.reason: "api_key_only"): the declaration speaks for your code. A declaration replaces the one before whole, from whichever app sends it; the audit log records each change and names the app and key it replaced.
  • What was really sent. templates.get also returns inputs_seen: every merge name your real sends of that template carried, with a count and when first and last seen, names only, never values. templateInputDrift(template.inputs, template.inputs_seen) lists what you declare but never send and what you send but never declared.

The dashboard variant: server-only, per signed-in person

The mail service's own dashboard (apps/dashboard) signs people in through auth-brain and calls the service with its own service token plus the signed-in person's auth-brain subject and workspace. createDashboardMailClient must never run in a browser: its service token authenticates as the whole dashboard, not one person, and shipping it to a browser bundle would leak it to every visitor. The constructor throws immediately if it detects a window global, but the real guarantee has to come from where you call it: only from server-side code (a Next.js server action, route handler or server component).

// lib/dashboard-mail-client.ts (server-only module)
import { createDashboardMailClient } from '@marlinjai/mail-sdk';

const dashboardMail = createDashboardMailClient({
  baseUrl: process.env.MAIL_SERVICE_URL!,
  serviceToken: process.env.MAIL_DASHBOARD_SERVICE_TOKEN!,
});

export function mailClientFor(subject: string, workspaceId: string) {
  return dashboardMail.forUser({ subject, workspaceId });
}
// app/dashboard/mailings/actions.ts ("use server")
import { auth } from '@/lib/auth-brain'; // however the app resolves the signed-in person
import { mailClientFor } from '@/lib/dashboard-mail-client';

export async function pauseMailing(mailingId: string) {
  const session = await auth();
  const client = mailClientFor(session.subject, session.workspaceId);
  return client.mailings.pause(mailingId);
}

The service checks that subject's membership and role in workspaceId on every call; the SDK never assumes the caller is authorized, it only carries the headers.

Creating a workspace: the one call with no workspace yet

workspaces.create and workspaces.list are dashboard-access routes: they run before the signed-in person has a workspace to be scoped to, so workspaceId is optional on forUser for exactly these two calls (every other route needs it, and the service checks membership against it on every call).

const client = dashboardMail.forUser({ subject: session.subject }); // no workspaceId yet
const workspace = await client.workspaces.create({
  slug: 'opuntia',
  name: 'ŌPUNTIA',
  owner: { email: session.email, name: session.name },
});
// From here on, calls for this workspace pass its id: forUser({ subject, workspaceId: workspace.id }).

Adding a person to an existing workspace binds them by their auth-brain subject, not an email invite (the service never sees a login, so the caller resolves the person first):

await client.members.add({ subject: person.subject, email: person.email, name: person.name, role: 'editor' });

A webhook receiver

The webhook signature helpers and the WebhookEvent union are re-exported from @marlinjai/mail-contract, so a receiver needs only this one package.

// app/api/mail-webhooks/route.ts
import { WEBHOOK_SIGNATURE_HEADER, WEBHOOK_TIMESTAMP_HEADER, WebhookEvent, verifyWebhook } from '@marlinjai/mail-sdk';

export async function POST(req: Request) {
  const rawBody = await req.text();
  const check = await verifyWebhook({
    secret: process.env.MAIL_WEBHOOK_SECRET!,
    rawBody,
    signatureHeader: req.headers.get(WEBHOOK_SIGNATURE_HEADER),
    timestampHeader: req.headers.get(WEBHOOK_TIMESTAMP_HEADER),
  });
  if (!check.ok) return new Response(check.reason, { status: 401 });

  const event = WebhookEvent.parse(JSON.parse(rawBody));
  // Deliveries may repeat: deduplicate on event.id before acting on it.
  switch (event.type) {
    case 'message.sent':
      // archive event.data.html next to your own record of the send
      break;
    case 'contact.unsubscribed':
      // mirror the unsubscribe into your own system of record
      break;
    case 'contact.resubscribed':
      // the person opted back in on the hosted page: lift your mirror of the unsubscribe
      break;
    // ...
  }
  return new Response(null, { status: 204 });
}

Errors

Every failure is one of four typed classes; a switch on instanceof (or on MailApiError.code) is always enough, never on .message, which is for humans:

| Class | When | | --- | --- | | MailApiError | The service answered a non-2xx response. Carries code (ErrorCode from the contract), status, message, details and requestId. | | MailNetworkError | The request never reached the service (Domain Name System (DNS), Transport Layer Security (TLS), connection reset). | | MailTimeoutError | A single attempt exceeded timeoutMs. | | MailResponseValidationError | The service answered 2xx but the body did not match the contract's schema (a service bug or a contract version mismatch). Never retried: retrying an already-succeeded mutating call risks a duplicate. |

import { MailApiError } from '@marlinjai/mail-sdk';

try {
  await mail.mailings.send(mailingId);
} catch (err) {
  if (err instanceof MailApiError && err.code === 'missing_unsubscribe_url') {
    // the document has no {{unsubscribe_url}}; show the editor error, don't retry
  }
  throw err;
}

Retries

Retries apply only to network failures, request timeouts, and responses whose error code is in the contract's RETRYABLE_ERRORS (rate_limited, provider_error, internal_error, service_unavailable). Every other error, including daily_budget_exhausted and plan_limit_reached even though both are HTTP 429, is never retried: retrying them would not help, since the condition they report does not clear on its own within the request's lifetime. For the same reason a service_unavailable whose details.reason is billing_not_configured (checkout or the portal while Stripe is not set up) is answered at once; the contract's isRetryableError holds the rule.

  • Exponential backoff with full jitter, capped at 8 seconds between attempts.
  • Retry-After (seconds or a Hypertext Transfer Protocol (HTTP) date) is honoured when the service sends it, capped at 60 seconds so a large value can never hang a caller.
  • A fresh Idempotency-Key is minted once per call (via crypto.randomUUID()) and reused across every attempt of that call: this is what makes a retry safe. A caller may also pass its own key through the last opts argument any mutating method takes ({ idempotencyKey }).
  • maxRetries (default 3) and timeoutMs (default 10000, per attempt) are configurable on createMailClient.
  • A caller-provided AbortSignal (also in the last opts argument) is never itself retried: an abort you asked for propagates immediately.

Response headers: usage warnings

A successful call's headers are available through onResponse in the last opts argument. It receives the status, the request id, the raw Headers, and usageWarnings: the x-mail-usage-warning header (sent on mailings.send and mailings.test once the workspace is at 80 percent of a plan limit) already parsed into { metric, used, limit } entries.

let warnings: UsageWarningHeaderEntry[] = [];
await mail.mailings.send(mailingId, { onResponse: (meta) => (warnings = meta.usageWarnings) });
if (warnings.length > 0) {
  // e.g. "messages: 8200 of 10000 this period"; show it before the next send
}

onResponse runs once the body has parsed and validated, just before the call returns; it is not called for a failed call (a MailApiError carries its own status and request id). A limit that is already exceeded fails the call with plan_limit_reached (HTTP 429), whose details name the metric, the used count, the limit and the plan.

Health check

client.health() hits the service's liveness probe (HEALTH_PATH, outside /v1, no credentials) and never throws: a network failure or a non-2xx status both resolve false. Meant for a caller polling "is it up" (a deploy script, a monitor), not for anything that needs a typed error.

if (!(await mail.health())) {
  // back off and retry, or alert
}

Configuration

createMailClient({
  baseUrl: string; // the mail service's origin, e.g. "https://mail.lumitra.co"
  apiKey: string; // a workspace API key: `Authorization: Bearer <key>`
  fetch?: typeof fetch; // defaults to the runtime's global fetch
  timeoutMs?: number; // default 10000, per attempt
  maxRetries?: number; // default 3
  userAgent?: string;
  validateResponses?: boolean; // default true; disable only once you trust the deployment
});

Development

pnpm -F @marlinjai/mail-sdk run build   # tsup, dual CJS/ESM + .d.ts
pnpm -F @marlinjai/mail-sdk run lint    # tsc --noEmit (the linter for this repo)
pnpm -F @marlinjai/mail-sdk run test    # vitest, a mocked fetch, no network