sently
TypeScript icon, indicating that this package has built-in type declarations

0.8.0 • Public • Published

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

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: "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();

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: "onboarding@yourdomain.com",
  to: "recipient@example.com",
  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: "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");
  },
};

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: "you@example.com", 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: "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).

DKIM signing

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.

Gmail OAuth2 (XOAUTH2)

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

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

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

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: "you@example.com",
  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: "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.

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


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

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: "onboarding@yourdomain.com",
  to: "user@example.com",
  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: "you@example.com",
  to: "user@example.com",
  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: attacker@evil.com" 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: "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.

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