@kleb/mail (0.4.0)

Published 2026-07-14 11:00:17 +02:00 by kleb

Installation

@kleb:registry=
npm install @kleb/mail@0.4.0
"@kleb/mail": "0.4.0"

About this package

@kleb/mail

Provider-neutral mail primitives with a server-only SMTP sender.

import { createSmtpMailSender } from "@kleb/mail/server";

const sender = createSmtpMailSender({
  host: "smtp.example.com",
  port: 587,
  secure: false,
  pool: true,
  maxConnections: 5,
  rateLimit: 10,
  auth: {
    user: "user",
    pass: "secret",
  },
  retry: {
    maxAttempts: 3,
    delayMs: (attempt) => attempt * 250,
  },
});

await sender.send({
  from: { address: "hello@example.com", name: "Example" },
  to: "ada@example.com",
  subject: "Welcome",
  html: "<p>Hello Ada</p>",
  text: "Hello Ada",
});

Use provider adapters from explicit subpaths when SMTP is not the right transport:

import { createResendMailSender } from "@kleb/mail/resend";

const sender = createResendMailSender({
  apiKey: process.env.RESEND_API_KEY,
});

Available provider subpaths are @kleb/mail/resend, @kleb/mail/postmark, @kleb/mail/sendgrid, and @kleb/mail/ses.

Messages support threading, custom envelopes, list headers, DSN options, alternatives, AMP, calendar events, inline attachments via cid, SMTP DKIM, and OAuth2 SMTP auth. Each address string, including a custom envelope sender or recipient, must contain exactly one mailbox; use address arrays for multiple recipients. File and URL attachments are only available from @kleb/mail/server and require explicitly enabling Nodemailer file or URL access on the server sender.

import { createSmtpMailSender, type ServerMailMessage } from "@kleb/mail/server";

const message: ServerMailMessage = {
  from: "hello@example.com",
  to: "ada@example.com",
  subject: "Report",
  text: "See attached.",
  attachments: [{ filename: "report.pdf", path: "C:\\reports\\report.pdf" }],
};

const sender = createSmtpMailSender({
  host: "smtp.example.com",
  port: 587,
  disableFileAccess: false,
});

Use @kleb/templating to render email HTML/text, then pass the rendered output to this package.

SMTP senders validate messages before handing them to Nodemailer. Use the provider-neutral helpers when composing custom senders:

import {
  assertValidMailMessage,
  createRetryingMailSender,
  createValidatingMailSender,
} from "@kleb/mail";

const sender = createValidatingMailSender(
  createRetryingMailSender(providerSender, {
    maxAttempts: 3,
    delayMs: 500,
  }),
);

assertValidMailMessage(message);
await sender.send(message);

Validation covers address fields, subjects, custom headers, and metadata that becomes SMTP/MIME headers, including attachment filenames, list headers, DSN fields, calendar metadata, message IDs, and references. Control characters in these fields are rejected before a provider SDK or Nodemailer receives the message. Every address input must represent exactly one mailbox. Raw unquoted display names accept only letters, numbers, spaces, underscores, apostrophes, and dashes; safely parsed quoted display names remain available for punctuation. Structured display names are safely quoted and escaped for provider SDKs; Nodemailer continues to receive its native structured address object.

maxAttachmentBytes measures inline UTF-8 string/Uint8Array content without reading external resources. Inline text using another declared encoding and server-only path or href attachments are rejected when that option is set because their decoded size cannot be verified portably before send. The package never fetches a URL merely to validate it, and Nodemailer URL access remains disabled unless disableUrlAccess: false is explicitly configured.

The SendGrid adapter creates an isolated SDK client for each API-key sender. Inject a client explicitly when tests or applications need full control over SendGrid transport state.

Use the outbox interfaces when mail retries need to survive process restarts:

import { enqueueMail, processDueMail } from "@kleb/mail";

await enqueueMail(store, message);
await processDueMail(store, sender);

Explicit outbox IDs must be unique, and maxAttempts must be a positive safe integer. Reusing an existing ID throws instead of replacing a queued or completed job. Durations, concurrency, retry delays, and dates are validated before work is claimed.

MailOutboxStore.claimNext(now, options?) atomically claims the next due queued job or a sending job whose lease expired (reclaimAfterMs, default 60s). Every claim increments attempts; an already-exhausted reclaimed job is failed before another provider send. Retry due times are computed from send completion, not claim start.

Long sends use renewLease(id, now, expectedAttempts) heartbeats. Completion, renewal, and shutdown release operations are fenced by the claim's attempts, so a superseded worker cannot overwrite newer state. markSent must resolve to true only when the matching active claim was settled and false when its fence is stale. Legacy Promise<void> implementations remain type-compatible for one release, but their settlement is reported as an unconfirmed error and never as sent: true; migrate stores to the boolean contract before the next release. renewLease, releaseClaim, and pruneTerminal are optional for one compatibility release, but persistent stores should implement all three. Without renewal a healthy long send can be reclaimed; without release a runner must wait beyond its shutdown deadline to avoid stranding the claim.

For a long-running consumer instead of hand-rolled polling, use runMailOutbox:

import { runMailOutbox } from "@kleb/mail";

const controller = new AbortController();
const done = runMailOutbox(store, sender, {
  intervalMs: 1_000,
  concurrency: 4,
  reclaimAfterMs: 60_000,
  heartbeatIntervalMs: 20_000,
  sendTimeoutMs: 30_000,
  shutdownGraceMs: 10_000,
  terminalJobRetentionMs: 7 * 24 * 60 * 60 * 1_000,
  signal: controller.signal,
  async onError(error, job) {
    console.error("mail outbox job failed", job?.id, error);
  },
});

// later, to stop: controller.abort(); await done;

runMailOutbox claims and sends with bounded concurrency, waits intervalMs when no job is due, and stops claiming new work as soon as signal aborts. Active sends receive a MailSendContext with an abort signal and shutdown deadline. The runner drains them for shutdownGraceMs, then aborts their contexts and uses fenced releaseClaim to make unfinished jobs due again. Built-in senders reject an already-aborted context; injected provider clients should also use the signal for true in-flight cancellation. onError may be async, and callback failures are contained rather than stopping the loop.

Use pruneTerminalMail(store, { retentionMs, limit }) for explicit cleanup, or set terminalJobRetentionMs on the runner. Only sent and failed jobs at or before the cutoff are deleted.

The outbox provides at-least-once processing, not exactly-once delivery. It defaults a queued message's idempotencyKey to the stable job id. Of the providers in this package, only Resend accepts that as a provider-side idempotency key (forwarded as Idempotency-Key). A provider may accept a message and then time out or the worker may crash before markSent; retrying is required to avoid loss and can duplicate delivery. Fences and heartbeats protect store state but cannot retract an accepted email. SendGrid, SES, Postmark, and SMTP have no equivalent key, so exactly-once delivery is impossible for the outbox to guarantee. Add provider- or application-level idempotency where available, and design recipients/content to tolerate duplicates.

Use the config integration when SMTP settings should be loaded through @kleb/config:

import { kConfig } from "@kleb/config/server";
import { smtpMailConfig } from "@kleb/mail/config";

const loaded = kConfig("app.json").section("smtp", smtpMailConfig()).load();

Dependencies

Dependencies

ID Version
@types/nodemailer 8.0.1
nodemailer ^9.0.3

Development dependencies

ID Version
@aws-sdk/client-sesv2 3.1079.0
@sendgrid/mail 8.1.6
postmark 4.0.7
resend 6.17.1

Peer dependencies

ID Version
@aws-sdk/client-sesv2 ^3.1079.0
@kleb/config ^0.6.0
@sendgrid/mail ^8.1.6
postmark ^4.0.7
resend ^6.17.1
Details
npm
2026-07-14 11:00:17 +02:00
11
UNLICENSED
21 KiB
Assets (1)
Versions (4) View all
0.5.0 2026-07-20
0.4.0 2026-07-14
0.3.0 2026-07-07
0.2.0 2026-07-04