@kleb/mail (0.4.0)
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 |