@norialabs/mailer
v0.1.3
Published
Official JavaScript SDK for the Noria Mailer API.
Maintainers
Readme
@norialabs/mailer
Official JavaScript SDK for the Noria Mailer API.
This package is standalone and publishable. It is not an internal monorepo helper and has no runtime dependency on the mailer service source tree.
Node 24+ is required. Use it in server-side Node.js and Next.js code, not in browser/client bundles.
Install
npm install @norialabs/mailerQuick Start
import { Mailer } from "@norialabs/mailer";
const mailer = new Mailer(process.env.NORIA_MAILER_API_KEY!, {
baseUrl: "https://mailer.example.com",
});
const email = await mailer.emails.send(
{
from: "Noria Demo <[email protected]>",
to: ["[email protected]"],
subject: "Hello from Noria Mailer",
text: "Your SDK is working.",
},
{
idempotencyKey: "demo-send-1",
},
);
console.log(email.id);What This SDK Supports
- High-level resources:
emails,domains,apiKeys,webhooks, andhealth - A low-level
mailer.request(...)method for unsupported or newly-added endpoints - Client-level and request-level transport configuration
- Custom auth strategies
- Middleware
- Retry policies
- Custom response parsing and transformation
- Forward-compatible request payloads for API fields the SDK has not typed yet
Constructor
const mailer = new Mailer(apiKey, {
baseUrl: "https://mailer.example.com",
timeoutMs: 30_000,
fetch,
query: {
region: "eu-west-1",
},
headers: {
"x-trace-source": "my-app",
},
retry: {
maxAttempts: 2,
},
middleware: [
async (request, next) => {
request.headers.set("x-sdk", "mailer");
return await next(request);
},
],
});Constructor Options
baseUrl: stringRequired. Must be an absolute URL. Path prefixes are preserved, sohttps://gateway.example.com/mailer-apibecomes the base for all requests.fetch?: typeof fetchOptional. Defaults to globalfetch.timeoutMs?: numberOptional. Defaults to30_000.headers?: HeadersInitOptional default headers applied to every request.query?: Record<string, string | number | boolean | Date | undefined | Array<...>>Optional default query params applied to every request.auth?: MailerAuthStrategy | falseOptional default auth strategy. If omitted andapiKeyis non-empty, the SDK uses bearer auth with that key. If omitted andapiKeyis empty, there is no default auth strategy.retry?: MailerRetryOptions | number | falseOptional default retry policy. Retries are disabled by default.middleware?: MailerMiddleware[]Optional middleware chain. Defaults to[].parseResponse?: MailerResponseParserOptional default response parser.transformResponse?: MailerResponseTransformerOptional default response transformer.
Constructor Defaults
timeoutMsdefaults to30_000fetchdefaults to globalfetchheadersdefaults to no extra headersquerydefaults to no extra query paramsretrydefaults to disabledmiddlewaredefaults to no middlewareparseResponsedefaults to: Empty body ->nullJSONcontent-type-> parsed JSON Non-JSON body that still parses as JSON -> parsed JSON Otherwise -> plain texttransformResponsedefaults to: Non-2xx/3xx -> throwMailerErrorSuccessful{ ok: true, data: ... }envelope -> returndataAny other successful payload -> return the parsed payload as-is- Default auth behavior:
Non-empty
apiKey-> bearer auth using that key EmptyapiKey-> no default auth
Config Precedence And Merge Rules
Request-level options override or extend constructor-level options.
fetch: request value replaces constructor valuetimeoutMs: request value replaces constructor valueauth: request value replaces constructor valueheaders: request headers merge over constructor headersquery: request query merges over constructor querymiddleware: constructor middleware runs first, request middleware runs after itparseResponse: request value replaces constructor valuetransformResponse: request value replaces constructor value
Header merging is last-write-wins by header name.
Query merging is key-based:
- if the request-level query provides a key, it replaces the constructor-level value for that key
- array values become repeated query params such as
tag=welcome&tag=trial Datevalues are serialized withtoISOString()undefinedvalues are ignored during query merging
Request-Level Options
Every resource method accepts request options. mailer.request(...) accepts the same options plus body.
await mailer.emails.send(
{
from: "Noria Demo <[email protected]>",
to: "[email protected]",
subject: "Hello",
text: "World",
scheduledAt: "2026-03-28T09:00:00.000Z",
},
{
headers: {
"x-tenant-id": "tenant_123",
},
timeoutMs: 10_000,
idempotencyKey: "tenant-123-send-1",
},
);Common Request Options
signal?: AbortSignalheaders?: HeadersInittimeoutMs?: numberfetch?: typeof fetchquery?: MailerQueryParamsauthenticated?: booleanauth?: MailerAuthStrategy | falseretry?: MailerRetryOptions | number | falsemiddleware?: MailerMiddleware[]parseResponse?: MailerResponseParsertransformResponse?: MailerResponseTransformerunwrapData?: boolean
Additional Request Options
idempotencyKey?: stringSupported byemails.send(...)andmailer.request(...)body?: MailerBodySupported bymailer.request(...)
Auth
The constructor apiKey is only a convenience default. Auth is fully customizable.
Auth Rules
- Authenticated requests default to
authenticated: true health.check()andhealth.ready()default toauthenticated: false- If
authenticatedisfalse, the SDK removes theauthorizationheader before sending - If
authenticatedistrueand there is no auth strategy and noauthorizationheader, the SDK throws:TypeError: Mailer auth is required for authenticated requests. - Request-level
authoverrides constructor-levelauth
Bearer Auth
const mailer = new Mailer("", {
baseUrl: "https://mailer.example.com",
auth: {
type: "bearer",
token: async (request) => await getTenantToken(request.path),
headerName: "authorization",
prefix: "Bearer",
},
});Bearer auth options:
token: string | (context) => string | Promise<string>headerName?: stringDefaults toauthorizationprefix?: stringDefaults toBearer
Header-Based Auth
const mailer = new Mailer("", {
baseUrl: "https://mailer.example.com",
auth: {
type: "headers",
headers: async (request) => ({
authorization: `Bearer ${await getTenantToken(request.path)}`,
"x-tenant-id": "tenant_123",
}),
},
});Supplying Auth Directly In Headers
const mailer = new Mailer("", {
baseUrl: "https://mailer.example.com",
auth: false,
});
await mailer.request("GET", "/secure-endpoint", {
headers: {
authorization: "Bearer pre-signed-token",
},
});Retry
Retries are off by default.
You can enable retries with either a number or an options object.
const mailer = new Mailer(apiKey, {
baseUrl: "https://mailer.example.com",
retry: 2,
});const mailer = new Mailer(apiKey, {
baseUrl: "https://mailer.example.com",
retry: {
maxAttempts: 3,
delayMs: async (context) => context.attempt * 250,
shouldRetry: async (context) => {
return context.response?.status === 409 || context.response?.status === 429;
},
},
});Retry Semantics
retry: falseor omitted -> no retriesretry: 2-> up to 2 total attemptsmaxAttemptsis total attempts, not additional retries- Default
shouldRetrybehavior: Network/runtime errors -> retryMailerErrorinstances -> do not retry HTTP statuses408,425,429,500,502,503,504-> retry - Default
delayMsbehavior:100ms,200ms,400ms, ... capped at1000ms delayMs <= 0retries immediately
Middleware
Middleware can inspect or modify the outgoing request and the parsed response.
const mailer = new Mailer(apiKey, {
baseUrl: "https://mailer.example.com",
middleware: [
async (request, next) => {
const startedAt = Date.now();
request.headers.set("x-request-source", "billing-worker");
const response = await next(request);
console.log(request.method, request.path, response.response.status, Date.now() - startedAt);
return response;
},
],
});Middleware receives:
request.methodrequest.pathrequest.urlrequest.headersrequest.bodyrequest.signalrequest.timeoutMsrequest.attempt
Middleware can:
- mutate
request.url - mutate
request.headers - inspect or replace the returned
MailerResponseContext
Response Parsing And Transformation
Default Parsing
By default the SDK reads the response body as text and then:
- returns
nullfor blank responses - parses JSON when
content-typeincludesapplication/json - attempts JSON parsing even when the header is missing or incorrect
- returns plain text if JSON parsing fails
Default Transformation
By default the SDK:
- throws
MailerErrorfor non-successful responses - unwraps
{ ok: true, data: ... }envelopes - returns other successful payloads unchanged
If you do not want envelope unwrapping, pass unwrapData: false.
const rawEnvelope = await mailer.request("GET", "/raw-envelope", {
unwrapData: false,
});Custom Parser
const total = await mailer.request("GET", "/stats", {
parseResponse: async (response) => response.headers.get("x-total"),
});Custom Transformer
const result = await mailer.request("GET", "/stats", {
parseResponse: async (response) => response.headers.get("x-total"),
transformResponse: ({ payload, response }) => ({
total: Number(payload),
status: response.status,
}),
});sendBatch(...) Default Response Behavior
emails.sendBatch(...) has a special default transformer:
- if the response payload is already an array, it returns that array
- if the response payload is an object with a
dataarray, it returns thedataarray - otherwise it returns the successful payload unchanged
Raw Requests
Use mailer.request(...) for endpoints the SDK does not expose yet.
const result = await mailer.request("POST", "/emails/preview", {
body: {
from: "Noria Demo <[email protected]>",
to: "[email protected]",
subject: "Preview",
templateId: "welcome-v2",
},
headers: {
"x-preview-mode": "true",
},
});Raw Request Signature
const result = await mailer.request<TResponse>(method, path, options);method: stringpath: stringRelative tobaseUrloptions: MailerRawRequestOptions
Supported Body Types
mailer.request(...) accepts:
- plain objects
- arrays
- strings
BlobFormDataURLSearchParamsArrayBuffer- typed arrays / other
ArrayBufferViews ReadableStreamnull
Body handling rules:
- object and array bodies are JSON-stringified
- if a JSON body is sent and no
content-typeis present, the SDK setscontent-type: application/json - native body types such as
FormDataandURLSearchParamsare passed through unchanged - if
bodyisundefined, no request body is sent
Forward-Compatible Request Payloads
Typed request interfaces only cover the fields the SDK currently knows about, but request helpers accept wider payloads at runtime.
That means you can pass newly-added API fields before the SDK adds explicit typings.
await mailer.emails.send({
from: "Noria Demo <[email protected]>",
to: "[email protected]",
subject: "Hello",
text: "World",
scheduledAt: "2026-03-28T09:00:00.000Z",
});Resource Reference
Emails
await mailer.emails.send({
from: "Noria Demo <[email protected]>",
to: "[email protected]",
subject: "Hello",
html: "<p>Hello</p>",
});
await mailer.emails.sendBatch([
{
from: "Noria Demo <[email protected]>",
to: "[email protected]",
subject: "One",
text: "First",
},
{
from: "Noria Demo <[email protected]>",
to: "[email protected]",
subject: "Two",
text: "Second",
},
]);
await mailer.emails.get("018f8c89-acde-7cc2-8a37-c7f2e051a123");
await mailer.emails.list({ limit: 25, offset: 0, status: "sent" });Methods:
mailer.emails.send(request, options?) -> Promise<{ id: string }>mailer.emails.sendBatch(requests, options?) -> Promise<SendEmailResult[] | unknown>mailer.emails.get(id, options?) -> Promise<Email>mailer.emails.list(options?) -> Promise<ListResponse<Email>>
Domains
await mailer.domains.create({ name: "example.com" });
await mailer.domains.list();
await mailer.domains.get("018f8c89-acde-7cc2-8a37-c7f2e051a123");
await mailer.domains.verify("018f8c89-acde-7cc2-8a37-c7f2e051a123");
await mailer.domains.remove("018f8c89-acde-7cc2-8a37-c7f2e051a123");Methods:
mailer.domains.create(request, options?) -> Promise<Domain>mailer.domains.list(options?) -> Promise<ListResponse<Domain>>mailer.domains.get(id, options?) -> Promise<Domain>mailer.domains.verify(id, options?) -> Promise<VerifyDomainResult>mailer.domains.remove(id, options?) -> Promise<DeleteDomainResult>
API Keys
await mailer.apiKeys.create({
name: "Primary live key",
environment: "live",
});
await mailer.apiKeys.list();
await mailer.apiKeys.get("018f8c89-acde-7cc2-8a37-c7f2e051a123");
await mailer.apiKeys.remove("018f8c89-acde-7cc2-8a37-c7f2e051a123");Methods:
mailer.apiKeys.create(request?, options?) -> Promise<CreatedApiKey>mailer.apiKeys.list(options?) -> Promise<ApiKey[]>mailer.apiKeys.get(id, options?) -> Promise<ApiKey>mailer.apiKeys.remove(id, options?) -> Promise<RevokeApiKeyResult>
Special behavior:
expiresAtcan be a string orDateDatevalues are serialized to ISO strings automaticallyapiKeys.create()may be called without a request body
Webhooks
await mailer.webhooks.create({
url: "https://example.com/webhooks/mailer",
events: ["email.sent", "email.delivered"],
});
await mailer.webhooks.list();
await mailer.webhooks.remove("018f8c89-acde-7cc2-8a37-c7f2e051a123");Methods:
mailer.webhooks.create(request, options?) -> Promise<WebhookEndpoint>mailer.webhooks.list(options?) -> Promise<WebhookEndpoint[]>mailer.webhooks.remove(id, options?) -> Promise<DeleteWebhookResult>
Webhook events are open-ended strings. The SDK ships known event literals but does not block newer event names.
Health
await mailer.health.check();
await mailer.health.ready();Methods:
mailer.health.check(options?) -> Promise<HealthStatus>mailer.health.ready(options?) -> Promise<HealthStatus>
Special behavior:
- both health endpoints default to unauthenticated requests
- you can opt back into auth by passing
{ authenticated: true }
Error Handling
import { Mailer, MailerError } from "@norialabs/mailer";
try {
await mailer.emails.send({
from: "Noria Demo <[email protected]>",
to: "[email protected]",
subject: "Hello",
text: "World",
});
} catch (error) {
if (error instanceof MailerError) {
console.error(error.statusCode, error.code, error.message, error.details, error.responseBody);
}
}MailerError includes:
statusCodecodedetailsresponseBody
Non-successful responses are converted to MailerError using these rules:
- structured
{ ok: false, error: ... }payloads -> useerror.message,error.code, anderror.details Errorpayloads -> useerror.message- non-empty text payloads -> use the text as the message
- object payloads without a structured envelope -> use a generic status-based message
- empty payloads -> use a generic status-based message
Type Notes
MailerRequestContext,MailerResponseContext,MailerRetryContext, and the auth/middleware/parser/transformer types are exported for advanced integrations- the package exports both
Maileranddefault - all exports are ESM-only
Development
npm install
npm run typecheck
npm test
npm test -- --coveragePublishing
The package is configured for public npm publishing under the @noria scope.
npm login
npm publish --access publicprepublishOnly runs typecheck plus the enforced 100% coverage gate before publish.
