Email, SMS, WhatsApp, and Web Push — one channel-first API for Node.js, Bun, Deno, and Cloudflare Workers. Zero runtime dependencies.
Runs on Node · Bun · Deno · Cloudflare Workers
import { createMailer } from "sently/mailer";
import { ResendTransport } from "sently/transports/resend";
const mailer = await createMailer({
transport: new ResendTransport({ apiKey: process.env.RESEND_API_KEY! }),
});
await mailer.send({
from: "hello@example.com",
to: "you@example.com",
subject: "Welcome",
html: "<p>Sent with sently.</p>",
});Apps use sently channel senders, not vendor SDKs. Providers are transports under those senders — swap Resend for SES, or Twilio for Taqnyat, without rewriting your call sites.
Tree-shakeable subpaths keep HTTP email around ~6 KB gzip. SMTP, DKIM, OAuth2, pooling, plugins, idempotency, and webhook parsers ship in the same package.
No dashboard, no per-send pricing, no vendor lock-in. Define senders in code, plug a transport, and let the type system keep call sites honest across every runtime.
Apps call createMailer / createSmsSender — not vendor SDKs. Swap the transport; call sites stay put.
One provider per subpath. Resend today, SES tomorrow — same sender contract.
Node, Bun, Deno, and Cloudflare Workers from one ESM package. Socket adapters where SMTP needs them.
Import sently/mailer for HTTP email (~6 KB gzip). SMTP, SMS, WhatsApp, and push stay out until you need them.
Retry, fallback, weighted failover, and idempotency wrap any transport without a new API.
SentlyError with codes like RATE_LIMITED and BAD_REQUEST — catch by code, not string matching.
| Concern | Typical stack | sently |
|---|---|---|
| Runtimes | Node only | Node · Bun · Deno · CF Workers |
| Channels | Email (+ separate SDKs) | Email · SMS · WhatsApp · Push |
| Providers | Vendor clients in app code | Pluggable transports |
Every send follows the same path: pick a channel sender, pass a transport, call send. Hooks observe; decorators strengthen the path without a new API.
Observe without changing the send — onSend, onSuccess, onError, onRetry. No body or PII in hook context.
Wrap any transport — RetryTransport, FallbackTransport, IdempotencyTransport. Same contract, stronger send path.
Pick a sender for the channel. Pass a transport for the provider. Vendor extras (OTP helpers, account utilities) stay on the concrete transport — never on the shared contract.
HTTP email from sently/mailer. SMTP from sently/smtp. SMS, WhatsApp, and push from their own subpaths. Each transport is a separate entry so unused providers stay out of the bundle.
Sender → transport → send. The same shape for every channel; only the options and providers change.