
Security News
US Government Forces Anthropic to Pull Claude Fable Days After Launch
Anthropic says the directive cited national security concerns over a narrow jailbreak, but offered no specific technical details.
Runtime-agnostic email library for Node.js, Bun, Deno, and Cloudflare Workers. ESM-native Nodemailer alternative with zero dependencies.
Nodemailer is Node.js–only and ships the full mail stack on every import (~58 KB gzip for v8.0.10). sently runs on Bun, Deno, and Cloudflare Workers — same familiar API, HTTP stacks from ~6.1 KB via
sently/mailer.
bun add sently
Pre-1.0 — API may change. sently is pre-1.0 and the public API is still being refined ahead of a stable v1.0.0. Breaking changes can land in any 0.x release; review the CHANGELOG before upgrading. Pin an exact version (e.g.
"sently": "0.8.0") for production until v1.0.0.
Getting started
Sending mail
Reference
| Feature | Nodemailer | sently |
|---|---|---|
| Bundle size | ~58 KB gzip always (v8.0.10) | ~6.1 KB HTTP · ~15 KB SMTP |
| Runtimes | Node.js only | Node, Bun, Deno, CF Workers |
| Module format | CommonJS | ESM only |
| Dependencies | 0 | 0 |
| DKIM signing | ✓ via nodemailer-dkim | ✓ built-in (Web Crypto) |
| OAuth2 / XOAUTH2 | ✓ via plugin | ✓ built-in |
| Connection pooling | ✓ | ✓ |
| HTTP transports | ✓ via plugins | ✓ built-in (11 HTTP APIs + CF Email binding) |
| Provider failover | ✗ | ✓ FallbackTransport + weighted routing |
| Retry transport | ✗ | ✓ |
| Preview transport | ✗ | ✓ |
| Template engine | ✗ | ✓ |
sendBulk() | ✗ | ✓ (native batch on Resend/SendGrid) |
| React Email | ✗ via plugin | ✓ sently/react |
| Idempotency keys | ✗ | ✓ sently/idempotency |
| Webhook parsing | ✗ | ✓ sently/webhooks |
| TypeScript | via @types/nodemailer | ✓ built-in |
| Last release | 2026 (8.0.x) | 2026 |
import type { MailOptions } from "sently";
import { createMailer } from "sently/mailer";
import { ResendTransport } from "sently/transports/resend";
import { PreviewTransport } from "sently/transports/preview";
const addFooter = (options: MailOptions): MailOptions => ({
...options,
html: (options.html ?? "") + '<p style="color:#999">Unsubscribe</p>',
});
// Swap providers without changing send code
const mailer = await createMailer({
transport: new ResendTransport({ apiKey: process.env.RESEND_API_KEY! }),
plugins: [addFooter],
});
await mailer.send({
from: "you@example.com",
to: "recipient@example.com",
subject: "Hello from sently",
html: "<p>Hello!</p>",
});
// Bulk send with concurrency control
await mailer.sendBulk(recipients, { concurrency: 5 });
// Local dev — write to disk instead of sending
const devMailer = await createMailer({
transport: process.env.CI
? new ResendTransport({ apiKey: process.env.RESEND_API_KEY! })
: new PreviewTransport({ outDir: ".emails", open: true }),
});
npm (sently):
bun add sently
npm install sently
pnpm add sently
JSR (@alialnaghmoush/sently) — Deno, Bun, and other JSR-aware runtimes:
deno add jsr:@alialnaghmoush/sently
bunx jsr add @alialnaghmoush/sently
import { createMailer } from "sently/mailer"; // HTTP stack ~6.1 KB with a transport
import { createSMTPMailer } from "sently/smtp"; // SMTP relay ~15 KB
// Or: import { createSMTPMailer } from "sently";
import { createSMTPMailer } from "sently/smtp";
const mailer = await createSMTPMailer({
host: "smtp.example.com",
port: 587,
auth: { user: "you@example.com", pass: "secret" },
});
await mailer.send({
from: "you@example.com",
to: "recipient@example.com",
subject: "Hello from sently",
text: "Plain text body",
html: "<p>HTML body</p>",
});
await mailer.close();
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: "onboarding@yourdomain.com",
to: "recipient@example.com",
subject: "Hello from the edge",
html: "<p>Sent via Resend + sently</p>",
});
SMTP relay (outbound TCP via cloudflare:sockets):
import { createSMTPMailer } from "sently/smtp";
import { CloudflareAdapter } from "sently/adapters/cf";
export default {
async fetch() {
const mailer = await createSMTPMailer({
host: "smtp.example.com",
port: 587,
auth: { user: "relay@example.com", pass: "secret" },
adapter: new CloudflareAdapter(),
});
await mailer.send({
from: "relay@example.com",
to: "user@example.com",
subject: "From a Worker",
text: "Hello from Cloudflare Workers",
});
return new Response("Sent");
},
};
Workers Email binding ([[send_email]] in wrangler.toml — no fetch HTTP API):
import { createMailer } from "sently/mailer";
import { CloudflareEmailTransport } from "sently/transports/cloudflare-email";
export default {
async fetch(_request, env) {
const mailer = await createMailer({
transport: new CloudflareEmailTransport({ sendEmail: env.SEND_EMAIL }),
});
await mailer.send({
from: "noreply@yourdomain.com",
to: "user@example.com",
subject: "From a Worker",
text: "Sent via send_email binding",
});
return new Response("Sent");
},
};
| Runtime | Import | Notes |
|---|---|---|
| Node.js (auto) | createSMTPMailer from sently/smtp | Auto-detected adapter |
| Node.js (explicit) | sently/adapters/node → NodeAdapter | Reference implementation |
| Bun (auto) | createSMTPMailer from sently/smtp | Auto-detected adapter |
| Bun (explicit) | sently/adapters/bun → BunAdapter | Node compat layer |
| Deno | sently/adapters/deno → DenoAdapter | Native Deno.startTls |
| Cloudflare Workers | sently/adapters/cf → CloudflareAdapter | cloudflare:sockets |
import { createSMTPMailer } from "sently/smtp";
import { NodeAdapter } from "sently/adapters/node";
const mailer = await createSMTPMailer({
host: "smtp.example.com",
adapter: new NodeAdapter({ secure: false }),
auth: { user: "you@example.com", pass: "secret" },
});
import { createMailer } from "sently/mailer";
import { SMTPTransport } from "sently/transports/smtp";
import { NodeAdapter } from "sently/adapters/node";
const transport = new SMTPTransport({
host: "smtp.example.com",
port: 587,
auth: { user: "you@example.com", pass: "secret" },
adapter: new NodeAdapter(),
});
const mailer = await createMailer({ transport });
await mailer.verify(); // test connection + auth
For relay config (host / port / auth), prefer sently/smtp. Use mailer + SMTPTransport when you need an explicit adapter or transport-level options.
AUTH methods: XOAUTH2, CRAM-MD5, LOGIN, and PLAIN (auto-negotiated from EHLO unless auth.type is set).
requireTLS (default true when auth is set): sently refuses to send credentials over an unencrypted connection. If the link is not secured by direct TLS (secure: true) or a successful STARTTLS upgrade, authentication throws an SMTPError instead of leaking credentials — this defends against STARTTLS-stripping MITM attacks. Set requireTLS: false only if you fully trust the network (not recommended).
import { createSMTPMailer } from "sently/smtp";
const mailer = await createSMTPMailer({
host: "smtp.example.com",
auth: { user: "you@example.com", pass: "secret" },
dkim: {
domainName: "example.com",
keySelector: "2024",
privateKey: await Bun.file("dkim-private.pem").text(),
},
});
Pass dkim on SMTP config or use signDKIM from sently/dkim directly. MIME lazy-loads DKIM only when the option is set.
import { createSMTPMailer } from "sently/smtp";
const mailer = await createSMTPMailer({
host: "smtp.gmail.com",
port: 465,
secure: true,
auth: {
type: "OAUTH2",
user: "me@gmail.com",
oauth2: {
user: "me@gmail.com",
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
refreshToken: process.env.GOOGLE_REFRESH_TOKEN!,
},
},
});
import { MICROSOFT_TOKEN_URL } from "sently";
import { createSMTPMailer } from "sently/smtp";
const mailer = await createSMTPMailer({
host: "smtp.office365.com",
port: 587,
auth: {
type: "OAUTH2",
user: "you@yourtenant.onmicrosoft.com",
oauth2: {
user: "you@yourtenant.onmicrosoft.com",
clientId: process.env.AZURE_CLIENT_ID!,
clientSecret: process.env.AZURE_CLIENT_SECRET!,
refreshToken: process.env.AZURE_REFRESH_TOKEN!,
tokenUrl: MICROSOFT_TOKEN_URL,
},
},
});
import { createSMTPMailer } from "sently/smtp";
const mailer = await createSMTPMailer({
host: "smtp.example.com",
pool: true,
maxConnections: 5,
maxMessages: 100,
rateDelta: 10,
rateLimit: 1000,
auth: { user: "you@example.com", pass: "secret" },
});
Or use SMTPPool directly:
import { SMTPPool } from "sently/pool";
const pool = new SMTPPool({
host: "smtp.example.com",
adapter: new NodeAdapter(),
auth: { user: "you@example.com", pass: "secret" },
});
| Transport | Import path | Required config |
|---|---|---|
| Mailer wrapper | sently/mailer | — (use with any transport below) |
| Resend | sently/transports/resend | apiKey |
| SendGrid | sently/transports/sendgrid | apiKey |
| Postmark | sently/transports/postmark | serverToken |
| Mailgun | sently/transports/mailgun | apiKey, domain |
| AWS SES | sently/transports/ses | accessKeyId, secretAccessKey, region |
| Brevo | sently/transports/brevo | apiKey |
| MailerSend | sently/transports/mailersend | apiToken |
| Plunk | sently/transports/plunk | apiKey |
| SparkPost | sently/transports/sparkpost | apiKey, euRegion? |
| Mailtrap | sently/transports/mailtrap | apiToken, sandbox?, inboxId? |
| Loops | sently/transports/loops | apiKey, defaultTransactionalId? |
| Cloudflare Email | sently/transports/cloudflare-email | sendEmail binding (env.SEND_EMAIL) |
All transports implement the same interface — swap without changing your send code.
Routing decorators (compose with any transport above):
| Transport | Import path | Purpose |
|---|---|---|
| Fallback | sently/transports/fallback | Ordered provider failover |
| Weighted fallback | sently/transports/weighted-fallback | Weighted-random primary + failover |
| Retry | sently/transports/retry | Per-provider retries before failing over |
| Idempotency | sently/idempotency | Dedupe sends on retry/replay |
Loops is template-first: subject/html/text are ignored. Set options.headers['x-loops-transactional-id'] (or defaultTransactionalId on the transport) and pass template variables via options.data.
Plunk sends one HTTP request per to address and aggregates results when multiple recipients are provided.
Route through an ordered list of providers — if your primary has an outage, the next takes over. Compose with RetryTransport to retry within a provider before failing over:
import { FallbackTransport } from "sently/transports/fallback";
import { RetryTransport } from "sently/transports/retry";
import { ResendTransport } from "sently/transports/resend";
import { SESTransport } from "sently/transports/ses";
import { createMailer } from "sently/mailer";
const transport = new FallbackTransport([
new RetryTransport(new ResendTransport({ apiKey: process.env.RESEND_API_KEY! })),
new RetryTransport(
new SESTransport({
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
}),
),
]);
const mailer = await createMailer({ transport });
const result = await mailer.send({ from: "...", to: "...", subject: "...", html: "..." });
// result.provider === "ses", result.providerIndex === 1 → primary failed, secondary won
Permanent client errors (HTTP 400/401/403, SMTP 535) are not retried on the next provider — they would fail identically everywhere. When all providers fail, FallbackError.attempts lists each { provider, error } in order for debugging.
Cooldown — skip providers that recently failed until a cooldown expires:
const transport = new FallbackTransport(transports, { cooldownMs: 300_000 });
Full-chain verify — verify() returns the first healthy provider; use verifyAll() for per-provider visibility:
const { ok, providers } = await transport.verifyAll();
// providers: [{ provider: "resend", ok: true }, { provider: "ses", ok: false, message: "..." }]
Mailer onFallback hook — observability when failover happens (requires FallbackTransport in the stack):
const mailer = await createMailer({
transport,
hooks: {
onFallback: (_ctx, failedProvider, nextProvider, error) => {
console.log(`failover ${failedProvider} → ${nextProvider}`, error);
},
},
});
Weighted routing — shift traffic gradually between providers:
import { WeightedFallbackTransport } from "sently/transports/weighted-fallback";
const transport = new WeightedFallbackTransport([
{ transport: new ResendTransport({ apiKey }), weight: 80 },
{ transport: new SESTransport({ accessKeyId, secretAccessKey }), weight: 20 },
]);
Every transport exposes a stable Transport.provider string (e.g. "resend", "ses") for hooks, logs, and SendResult.provider.
Cloudflare Workers Email — use the send_email binding (not fetch HTTP). Configure [[send_email]] in wrangler.toml, then pass env.SEND_EMAIL:
import { CloudflareEmailTransport } from "sently/transports/cloudflare-email";
const mailer = await createMailer({
transport: new CloudflareEmailTransport({ sendEmail: env.SEND_EMAIL }),
});
Attachments are base64-encoded in the binding payload; use content: Uint8Array on Workers (no attachment.path).
Write emails to disk during local development instead of sending them:
import { PreviewTransport } from "sently/transports/preview";
import { createMailer } from "sently/mailer";
const mailer = await createMailer({
transport: new PreviewTransport({
outDir: "./.emails",
open: true,
format: "html",
}),
});
await mailer.send({
from: "dev@localhost",
to: "you@example.com",
subject: "Preview me",
html: "<h1>Hello</h1>",
});
Wrap any transport with automatic retries and configurable backoff:
import { RetryTransport } from "sently/transports/retry";
import { ResendTransport } from "sently/transports/resend";
import { createMailer } from "sently/mailer";
const transport = new RetryTransport(
new ResendTransport({ apiKey: process.env.RESEND_API_KEY! }),
{ maxAttempts: 3, backoff: "exponential", retryOn: [429, 503] },
);
const mailer = await createMailer({ transport });
Send multiple messages with concurrency control and per-message callbacks. When the transport implements sendBatch (Resend, SendGrid), attachment-free messages are sent via native batch endpoints; messages with attachments fall back to individual sends.
const result = await mailer.sendBulk(
[
{ from: "a@b.com", to: "1@example.com", subject: "One", text: "Hi" },
{ from: "a@b.com", to: "2@example.com", subject: "Two", text: "Hi" },
],
{
concurrency: 2,
stopOnError: false, // halt remaining sends after first failure when true
onSuccess: (_msg, index) => console.log(`Sent #${index}`),
onError: (_msg, index, err) => console.error(`Failed #${index}`, err),
},
);
console.log(result.sent, result.failed);
Resend batches up to RESEND_BATCH_MAX (100) messages per request — export from sently/transports/resend.
Optional observability hooks on createMailer fire for every send() and sendBulk() message (batch paths invoke hooks once per message, without double-firing). Hook context carries { messageId?, to, subject, provider } — no body fields, to avoid leaking PII into logs. onSuccess and onError accept an optional third argument durationMs (elapsed milliseconds).
import { consoleObserver } from "sently/observability";
const mailer = await createMailer({
transport: new ResendTransport({ apiKey: process.env.RESEND_API_KEY! }),
hooks: {
onSend: (ctx) => metrics.increment("email.send", { provider: ctx.provider }),
onSuccess: (ctx, result, durationMs) =>
metrics.histogram("email.duration", durationMs ?? 0),
onError: (ctx, err, durationMs) => metrics.increment("email.error"),
onRetry: (ctx, attempt, err) => metrics.increment("email.retry", { attempt }),
onFallback: (ctx, failed, next, err) =>
metrics.increment("email.fallback", { from: failed, to: next }),
},
});
// Quick start — log lifecycle events to the console
const devMailer = await createMailer({
transport: new ResendTransport({ apiKey: process.env.RESEND_API_KEY! }),
hooks: consoleObserver("[myapp]"),
});
Hooks are fully optional and zero-cost when unset. A throwing hook does not break the send — in non-production environments the error is logged with console.warn and the send continues. Pair onRetry with RetryTransport for per-attempt retry metrics; pair onFallback with FallbackTransport or WeightedFallbackTransport for failover observability.
Works with both sently/mailer ({ transport, hooks }) and SMTP config via createSMTPMailer ({ host, auth, hooks }).
Prevent duplicate sends on retry or replay. Wrap outside RetryTransport so all retry attempts share one key:
import { IdempotencyTransport } from "sently/idempotency";
import { RetryTransport } from "sently/transports/retry";
import { ResendTransport } from "sently/transports/resend";
const transport = new IdempotencyTransport(
new RetryTransport(new ResendTransport({ apiKey: process.env.RESEND_API_KEY! })),
{ ttlMs: 86_400_000 },
);
await mailer.send({
from: "you@example.com",
to: "user@example.com",
subject: "Hello",
text: "Hi",
idempotencyKey: "order-123-email", // or derive from messageId
});
Resend sends the Idempotency-Key HTTP header natively. Supply a shared store (Redis, Dragonfly) in production — MemoryIdempotencyStore is for single-process use.
Plugins transform MailOptions before the transport builds and sends the message. They run sequentially — each receives the output of the previous plugin.
import type { MailOptions } from "sently";
import { createSMTPMailer } from "sently/smtp";
const addFooter = (options: MailOptions) => ({
...options,
html: (options.html ?? "") + '<p style="color:#999">Unsubscribe</p>',
});
const mailer = await createSMTPMailer({
host: "smtp.resend.com",
port: 465,
secure: true,
auth: { user: "resend", pass: process.env.RESEND_API_KEY! },
plugins: [addFooter],
});
Works with SMTP config or custom transports:
import { createMailer } from "sently/mailer";
import { ResendTransport } from "sently/transports/resend";
const mailer = await createMailer({
transport: new ResendTransport({ apiKey: "re_..." }),
plugins: [addFooter],
});
Render HTML from named templates with zero dependencies:
import { templatePlugin, simpleEngine } from "sently/plugins/template";
import { createMailer } from "sently/mailer";
import { ResendTransport } from "sently/transports/resend";
const mailer = await createMailer({
transport: new ResendTransport({ apiKey: "re_..." }),
plugins: [
templatePlugin({
engine: simpleEngine,
templates: {
welcome: "<h1>Hello, {{name}}!</h1>",
},
}),
],
});
await mailer.send({
from: "onboarding@yourdomain.com",
to: "user@example.com",
subject: "Welcome",
template: "welcome",
data: { name: "Ali" },
});
Use a custom engine by passing any (template, data) => string function to templatePlugin.
Render React Email components to HTML and plain text (optional peers: react, @react-email/render):
import { reactPlugin } from "sently/react";
import { createMailer } from "sently/mailer";
import { ResendTransport } from "sently/transports/resend";
import { WelcomeEmail } from "./emails/welcome";
const mailer = await createMailer({
transport: new ResendTransport({ apiKey: "re_..." }),
plugins: [reactPlugin()],
});
await mailer.send({
from: "onboarding@yourdomain.com",
to: "user@example.com",
subject: "Welcome",
react: WelcomeEmail({ name: "Ali" }),
});
Explicit html / text always win over rendered output.
Normalize provider webhooks into a single event type — no server framework required:
import { parseResendWebhook, parseSesWebhook } from "sently/webhooks";
// Resend (Svix-style payload)
const events = parseResendWebhook(await request.json());
// AWS SES via SNS (handles SubscriptionConfirmation + double-encoded Message)
const sesEvents = parseSesWebhook(await request.json());
for (const event of events) {
console.log(event.type, event.messageId, event.recipient);
}
Parsers: Resend, SendGrid, Postmark, Mailgun, SES, Brevo. Optional HMAC verification helpers for Mailgun and Resend (verifyMailgunSignature, verifyResendSignature).
| Field | Type | Default | Description |
|---|---|---|---|
from | AddressInput | required | Sender address |
to | AddressInput | required | Recipients |
cc | AddressInput | — | CC recipients (visible in headers) |
bcc | AddressInput | — | BCC recipients (envelope only, not in headers) |
replyTo | AddressInput | — | Reply-To header |
subject | string | required | Email subject (RFC 2047 for non-ASCII) |
text | string | — | Plain text body |
html | string | — | HTML body |
attachments | Attachment[] | — | File attachments |
headers | Record<string, string> | — | Custom headers |
messageId | string | auto | Message-ID header |
idempotencyKey | string | — | Dedupe key for retry/replay (Resend sends as Idempotency-Key header) |
react | unknown | — | React element — use with reactPlugin() from sently/react |
date | Date | now | Date header |
priority | 'high' | 'normal' | 'low' | — | X-Priority / Importance |
encoding | 'utf-8' | 'ascii' | 'utf-8' | Character encoding hint |
await mailer.send({
from: "you@example.com",
to: "user@example.com",
subject: "With attachment",
text: "See attached",
attachments: [
{
filename: "report.pdf",
content: pdfBytes, // Uint8Array
contentType: "application/pdf",
},
],
});
attachment.path reads from disk — see Security (Attachments) for validation and basePath.
attachments: [
{
filename: "report.pdf",
path: "/path/to/report.pdf",
},
],
On Cloudflare Workers and browsers, use content: Uint8Array — attachment.path is not supported.
All transport errors extend SentlyError for unified handling while preserving existing class names and properties:
import { SentlyError } from "sently/errors";
import { SMTPError } from "sently/transports/smtp";
import { ResendError } from "sently/transports/resend";
// Each HTTP transport exports its own error class:
// SendGridError → sently/transports/sendgrid
// PostmarkError → sently/transports/postmark
// MailgunError → sently/transports/mailgun
// SESError → sently/transports/ses
// BrevoError → sently/transports/brevo
// CloudflareEmailError → sently/transports/cloudflare-email
// FallbackError → sently/transports/fallback (all providers failed; see .attempts)
try {
await mailer.send({ ... });
} catch (err) {
if (err instanceof SentlyError) {
console.error(err.sentlyCode); // e.g. "BAD_REQUEST", "RATE_LIMITED"
console.error(err.statusCode); // HTTP status when applicable
}
if (err instanceof SMTPError) {
console.error(err.code); // SMTP response code, e.g. 550 (numeric)
console.error(err.command); // failed command, e.g. "RCPT TO"
}
if (err instanceof ResendError) {
console.error(err.statusCode); // HTTP status code
console.error(err.code); // machine-readable, e.g. "BAD_REQUEST"
}
}
Use sentlyCode for unified machine-readable codes when a subclass shadows code (e.g. SMTP numeric codes, Brevo/SES provider API codes). Import error classes from their transport subpath — HTTP failures also expose statusCode.
sently is built to be secure by default — protections are enforced at the library's core chokepoints, so they apply to every transport and every address field without any extra configuration.
All addresses and display names are validated centrally in parseAddresses() (and re-asserted when rendering headers), before any normalization:
0x00–0x1F), DEL (0x7F), and the Unicode line/paragraph separators U+2028/U+2029."Foo\r\nBcc: attacker@evil.com" can no longer inject a header.await mailer.send({
from: "you@example.com",
to: { address: "victim@x.com\r\nBcc: attacker@evil.com" },
subject: "Hi",
text: "...",
});
// → throws: Email address contains a forbidden control character (0x0d)
MIME attachment filenames and custom attachment headers are likewise sanitized against header injection.
requireTLS (default true when auth is set) refuses to authenticate over a cleartext connection, defeating STARTTLS-stripping downgrade attacks.⚠️
attachment.pathreads files from disk. Never pass user-controlled paths without validation.
resolveAttachments() accepts an opt-in basePath that confines reads to an allowed directory and rejects path-traversal (including sibling-directory prefix tricks like /var/data-secret vs /var/data). Note: basePath does not dereference symlinks — use fs.realpath() first if symlink traversal is a concern.
Zero runtime dependencies — there is no transitive dependency tree to audit or to be compromised.
Sizes are minified + gzip per import path (bun run measure:size; CI: bun run check:size). Node built-ins and cloudflare:sockets are external.
Nodemailer ships ~58 KB gzip regardless of transport (BundlePhobia, v8.0.10). sently tree-shakes by subpath — pick the entry that matches how you send:
| How you send | Import | ~gzip |
|---|---|---|
| HTTP API (Resend, SendGrid, …) | sently/mailer + sently/transports/<provider> | ~6.1 KB |
SMTP relay (host / port) | sently/smtp (or createSMTPMailer from sently) | ~15 KB |
| Transport only (no mailer wrapper) | sently/transports/<provider> | ~4.7 KB |
Regenerate full tables with bun run measure:size:md. Measured 2026-05-31 (minified + gzip):
| What | Imports | ~gzip |
|---|---|---|
| HTTP — Resend | sently/mailer + sently/transports/resend | ~6.1 KB |
| HTTP — SendGrid | sently/mailer + sently/transports/sendgrid | ~5.9 KB |
| HTTP — transport only | sently/transports/resend (no createMailer wrapper) | ~4.7 KB |
| SMTP relay | sently/smtp with { host, port, auth } | ~14.8 KB |
| SMTP + Node adapter | sently/smtp + sently/adapters/node | ~14.8 KB |
| Main entry + HTTP | sently + HTTP transport via main createMailer | ~6.1 KB |
| What | Imports | ~gzip |
|---|---|---|
| sently/mailer | Transport-only createMailer (plugins, sendBulk) | ~2.6 KB |
| sently | Main entry — types, factories, OAuth2, SentlyError | ~2.6 KB |
| sently/smtp | SMTP createSMTPMailer — host/port, pool, adapters | ~14.7 KB |
// HTTP
import { createMailer } from "sently/mailer";
import { ResendTransport } from "sently/transports/resend";
// SMTP
import { createSMTPMailer } from "sently/smtp";
Main "sently" exports shared types, createMailer, createSMTPMailer, detectRuntime, OAuth2, SentlyError, consoleObserver, and the v0.8 HTTP providers (LoopsTransport, MailerSendTransport, …), plus FallbackTransport, WeightedFallbackTransport, and CloudflareEmailTransport. Webhooks, idempotency, DKIM, SMTP-only transports, and plugins remain separate subpaths for smallest bundles.
How do you send mail?
│
├─ HTTP API (Resend, SendGrid, …)
│ import { createMailer } from "sently/mailer"
│ import { ResendTransport } from "sently/transports/resend"
│ createMailer({ transport: new ResendTransport({ apiKey }) })
│
├─ SMTP relay (host / port / auth)
│ import { createSMTPMailer } from "sently/smtp"
│ createSMTPMailer({ host, port, auth })
│
├─ Provider failover / weighted routing
│ import { FallbackTransport } from "sently/transports/fallback"
│ import { WeightedFallbackTransport } from "sently/transports/weighted-fallback"
│ createMailer({ transport: new FallbackTransport([primary, backup]) })
│
└─ Custom / decorated transport (Retry, Idempotency, Preview)
import { createMailer } from "sently/mailer"
createMailer({ transport: new RetryTransport(inner) })
| Nodemailer | sently |
|---|---|
nodemailer.createTransport({...}) | await createSMTPMailer({...}) or createMailer({ transport }) |
transporter.sendMail(options) | mailer.send(options) |
transporter.verify() | mailer.verify() |
options.attachments[].path | Same (Node/Bun/Deno); use content on edge |
import nodemailer from 'nodemailer' | import { createMailer } from 'sently/mailer' (HTTP) or createSMTPMailer from 'sently/smtp' |
| CommonJS | ESM only |
| Node.js only | Node, Bun, Deno, CF Workers |
import type {
MailOptions,
MailPlugin,
SendResult,
Attachment,
SMTPConfig,
SMTPMailerOptions,
TransportMailerOptions,
} from "sently";
All types ship with the package — no separate @types/ install needed.
MIT
FAQs
Runtime-agnostic email library for Node.js, Bun, Deno, and Cloudflare Workers. ESM-native Nodemailer alternative with zero dependencies.
The npm package sently receives a total of 232 weekly downloads. As such, sently popularity was classified as not popular.
We found that sently demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Security News
Anthropic says the directive cited national security concerns over a narrow jailbreak, but offered no specific technical details.

Security News
A network of 152 Chrome live wallpaper extensions hid ad tracking and made extension-driven traffic look like Google search clicks.

Company News
Socket’s first CISO brings deep experience securing high-growth SaaS companies as open source supply chain threats accelerate.