@kleb/mail (0.3.0)

Published 2026-07-07 09:04:37 +02:00 by kleb

Installation

@kleb:registry=
npm install @kleb/mail@0.3.0
"@kleb/mail": "0.3.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. 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.

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. Reusing an existing ID throws instead of replacing a queued or completed job.

MailOutboxStore.claimNext(now, options?) atomically claims the next due queued job or a sending job whose lease expired (reclaimAfterMs, default 60s) — this is what guards against a crash or hung send stranding a job forever, and against two workers claiming the same job. processDueMail calls it once per invocation.

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,
  signal: controller.signal,
  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 — it still awaits any in-flight sends before resolving. A single job's failure is reported via onError and never stops the loop.

Outbox retries default a queued message's messageId to the job id so retries carry a stable identity. Of the providers this package supports, only Resend accepts that as a provider-side idempotency key (forwarded as Idempotency-Key), so only Resend sends are protected from duplicate delivery on a retried/outbox-replayed send that already succeeded. SendGrid, SES, Postmark, and SMTP have no equivalent — do not rely on the outbox alone for exactly-once delivery through those providers; add app-level deduplication (e.g. a unique constraint keyed by the outbox job id) if that matters.

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.5.0
@sendgrid/mail ^8.1.6
postmark ^4.0.7
resend ^6.17.1
Details
npm
2026-07-07 09:04:37 +02:00
17
UNLICENSED
17 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