@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-sdkQuick 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.upsertfirst. 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'smerge, which fills the template's{{first_name}}and other fields for this letter only.The language.
send_localepicks 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
otherwisetext) and is recorded as a miss (matched: falseininsertionsonmessage.sent, and inmailings.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 withmail.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.createrefuses asubjectthat 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 (avalidation_failed, anunknown_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
skippedwithskip_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.
sendanswers once the letter is queued, not delivered. Themessage.sentandmessage.failedwebhooks carrymailing_idand your metadata asmailing_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
MailApiErrorand 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;falseand absent are both No),textandurl(Has a value and Is empty),one_of(a row per value with your label, then Anything else and Not given).values[].valueis exactly what your form sends, in an insertion choice key's syntax (lowercase letters, digits,-and_). - Where it comes from:
form_fieldnames the form and the field as a person sees them on your site;derivedsays in anotehow 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.
emailandunsubscribe_urlare filled by the service and refused (input_reserved);first_nameandlast_nameare 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: falseand writes nothing; a newapp_versionalone movesdeclared_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
sendorfullscope. 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.getalso returnsinputs_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-Keyis minted once per call (viacrypto.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 lastoptsargument any mutating method takes ({ idempotencyKey }). maxRetries(default 3) andtimeoutMs(default 10000, per attempt) are configurable oncreateMailClient.- A caller-provided
AbortSignal(also in the lastoptsargument) 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