npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

sently

v0.8.0

Published

Runtime-agnostic email library for Node.js, Bun, Deno, and Cloudflare Workers. ESM-native Nodemailer alternative with zero dependencies.

Readme

sently

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

npm version JSR bundle size license tests GitHub

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.

Index

Getting started

Sending mail

Reference


Why not Nodemailer?

| 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 |


The 30-second tour

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: "[email protected]",
  to: "[email protected]",
  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 }),
});

Installation

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";

Quick Start

SMTP with auto-detected adapter

import { createSMTPMailer } from "sently/smtp";

const mailer = await createSMTPMailer({
  host: "smtp.example.com",
  port: 587,
  auth: { user: "[email protected]", pass: "secret" },
});

await mailer.send({
  from: "[email protected]",
  to: "[email protected]",
  subject: "Hello from sently",
  text: "Plain text body",
  html: "<p>HTML body</p>",
});

await mailer.close();

Resend HTTP transport (Vercel Edge compatible)

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: "[email protected]",
  to: "[email protected]",
  subject: "Hello from the edge",
  html: "<p>Sent via Resend + sently</p>",
});

Cloudflare Worker

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: "[email protected]", pass: "secret" },
      adapter: new CloudflareAdapter(),
    });

    await mailer.send({
      from: "[email protected]",
      to: "[email protected]",
      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: "[email protected]",
      to: "[email protected]",
      subject: "From a Worker",
      text: "Sent via send_email binding",
    });

    return new Response("Sent");
  },
};

Adapters

| Runtime | Import | Notes | |---------|--------|-------| | Node.js (auto) | createSMTPMailer from sently/smtp | Auto-detected adapter | | Node.js (explicit) | sently/adapters/nodeNodeAdapter | Reference implementation | | Bun (auto) | createSMTPMailer from sently/smtp | Auto-detected adapter | | Bun (explicit) | sently/adapters/bunBunAdapter | Node compat layer | | Deno | sently/adapters/denoDenoAdapter | Native Deno.startTls | | Cloudflare Workers | sently/adapters/cfCloudflareAdapter | 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: "[email protected]", pass: "secret" },
});

Transports

SMTP

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: "[email protected]", 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).

DKIM signing

import { createSMTPMailer } from "sently/smtp";

const mailer = await createSMTPMailer({
  host: "smtp.example.com",
  auth: { user: "[email protected]", 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.

Gmail OAuth2 (XOAUTH2)

import { createSMTPMailer } from "sently/smtp";

const mailer = await createSMTPMailer({
  host: "smtp.gmail.com",
  port: 465,
  secure: true,
  auth: {
    type: "OAUTH2",
    user: "[email protected]",
    oauth2: {
      user: "[email protected]",
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
      refreshToken: process.env.GOOGLE_REFRESH_TOKEN!,
    },
  },
});

Microsoft 365 OAuth2 (XOAUTH2)

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: "[email protected]",
    oauth2: {
      user: "[email protected]",
      clientId: process.env.AZURE_CLIENT_ID!,
      clientSecret: process.env.AZURE_CLIENT_SECRET!,
      refreshToken: process.env.AZURE_REFRESH_TOKEN!,
      tokenUrl: MICROSOFT_TOKEN_URL,
    },
  },
});

Connection pooling

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: "[email protected]", pass: "secret" },
});

Or use SMTPPool directly:

import { SMTPPool } from "sently/pool";

const pool = new SMTPPool({
  host: "smtp.example.com",
  adapter: new NodeAdapter(),
  auth: { user: "[email protected]", pass: "secret" },
});

HTTP APIs

| 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.

FallbackTransport

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 verifyverify() 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).

PreviewTransport

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: "[email protected]",
  subject: "Preview me",
  html: "<h1>Hello</h1>",
});

RetryTransport

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 });

sendBulk()

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: "[email protected]", to: "[email protected]", subject: "One", text: "Hi" },
    { from: "[email protected]", to: "[email protected]", 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.

Mailer lifecycle hooks

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 }).

IdempotencyTransport

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: "[email protected]",
  to: "[email protected]",
  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.


Plugin system

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],
});

TemplatePlugin

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: "[email protected]",
  to: "[email protected]",
  subject: "Welcome",
  template: "welcome",
  data: { name: "Ali" },
});

Use a custom engine by passing any (template, data) => string function to templatePlugin.

React Email plugin

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: "[email protected]",
  to: "[email protected]",
  subject: "Welcome",
  react: WelcomeEmail({ name: "Ali" }),
});

Explicit html / text always win over rendered output.

Webhook parsing

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).


MailOptions Reference

| 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 |


Attachments

In-memory (all runtimes)

await mailer.send({
  from: "[email protected]",
  to: "[email protected]",
  subject: "With attachment",
  text: "See attached",
  attachments: [
    {
      filename: "report.pdf",
      content: pdfBytes, // Uint8Array
      contentType: "application/pdf",
    },
  ],
});

File path (Node.js / Bun / Deno only)

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: Uint8Arrayattachment.path is not supported.


Error Handling

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.


Security

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.

Email header & SMTP command injection

All addresses and display names are validated centrally in parseAddresses() (and re-asserted when rendering headers), before any normalization:

  • Rejects CR, LF, NUL, every other C0 control (0x000x1F), DEL (0x7F), and the Unicode line/paragraph separators U+2028/U+2029.
  • Fails closed: hostile input throws a clear error (with the offending code point) — it is never stripped, repaired, and then accepted.
  • Protects the display name too, so an ASCII name like "Foo\r\nBcc: [email protected]" can no longer inject a header.
  • Enforced consistently across From, To, Cc, Bcc, and Reply-To, and across every transport (SMTP, SES, Mailgun, Postmark, Resend, SendGrid, Brevo).
await mailer.send({
  from: "[email protected]",
  to: { address: "[email protected]\r\nBcc: [email protected]" },
  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.

Credential protection

  • requireTLS (default true when auth is set) refuses to authenticate over a cleartext connection, defeating STARTTLS-stripping downgrade attacks.
  • OAuth2 / XOAUTH2 and DKIM signing are built in via Web Crypto — no plaintext secrets in transit beyond what the protocol requires.

Attachments

⚠️ attachment.path reads 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.

Supply chain

Zero runtime dependencies — there is no transitive dependency tree to audit or to be compromised.


Bundle size

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):

Common stacks

| 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 |

Core entries

| 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.


Choosing an entrypoint

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) })

Migrating from Nodemailer

| 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 |


TypeScript

import type {
  MailOptions,
  MailPlugin,
  SendResult,
  Attachment,
  SMTPConfig,
  SMTPMailerOptions,
  TransportMailerOptions,
} from "sently";

All types ship with the package — no separate @types/ install needed.


Links

License

MIT