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

@devstevenjs/esendy

v1.1.1

Published

Secure, reusable email sender with React Email templates for Next.js and Vite projects

Readme

esendy

Secure, reusable email sender for Next.js and Vite projects. Built on top of nodemailer with React Email templates, input sanitization, header injection prevention, and built-in rate limiting.


Features

  • SMTP with enforced TLS (no plaintext fallback, cert verification always on)
  • CRLF header injection prevention on all header fields
  • HTML sanitization via sanitize-html (strips XSS, javascript: URLs, dangerous attributes)
  • RFC 5322 email address validation
  • Sliding window rate limiter (in-memory, per key)
  • React Email templates with opinionated default design, fully overridable per project
  • Auto-generated plain-text fallback from HTML
  • Optional Telegram delivery — mirrors each sent email to a single configured group via a bot (native fetch, zero new dependencies)
  • TypeScript-first, full type definitions

Installation

npm install @devstevenjs/esendy

For projects on React 18, add --legacy-peer-deps (esendy's React Email renderer requires React 19). Next.js 15+ projects are on React 19 by default and install cleanly.


Environment Variables

Esendy never reads env vars directly — you pass the config explicitly so it works in any framework. These are the typical env vars you define in each project:

ESENDY_SMTP_HOST=mail.yourdomain.com
ESENDY_SMTP_PORT=587
[email protected]
ESENDY_SMTP_PASS=yourpassword
ESENDY_FROM_NAME=My App
[email protected]

# Optional — Telegram delivery (see "Telegram delivery" section below)
ESENDY_TELEGRAM_BOT_TOKEN=123456:ABC-DEF...
ESENDY_TELEGRAM_CHAT_ID=-1001234567890

Quick Start

1. Create a shared mailer instance

Create this file once per project and import it wherever you need to send email.

// lib/mailer.ts  (Next.js)
// src/lib/mailer.ts  (Vite + Express/Fastify)

import { createEsendy } from '@devstevenjs/esendy'

export const mailer = createEsendy({
  smtp: {
    host: process.env.ESENDY_SMTP_HOST!,
    port: Number(process.env.ESENDY_SMTP_PORT) || 587,
    auth: {
      user: process.env.ESENDY_SMTP_USER!,
      pass: process.env.ESENDY_SMTP_PASS!,
    },
  },
  from: {
    name: process.env.ESENDY_FROM_NAME ?? 'My App',
    address: process.env.ESENDY_SMTP_USER!,
  },
  rateLimit: {
    windowMs: 60_000, // 1 minute
    max: 5,           // max 5 emails per minute per key (typically per IP)
  },
  defaults: {
    siteName: 'My App',
    primaryColor: '#2563eb',  // override per project
    // logoUrl: 'https://yourdomain.com/logo.png',
    // footerText: '© 2026 My App · yourdomain.com',
  },
  // Optional — mirrors every sent email to a Telegram group (gracefully no-ops when unset)
  telegram: process.env.ESENDY_TELEGRAM_BOT_TOKEN
    ? {
        botToken: process.env.ESENDY_TELEGRAM_BOT_TOKEN,
        chatId: process.env.ESENDY_TELEGRAM_CHAT_ID!,
      }
    : undefined,
})

Usage: Contact Form

Next.js (App Router — API Route)

// app/api/contact/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { mailer } from '@/lib/mailer'
import { ContactTemplate } from '@devstevenjs/esendy/templates'
import { EsendyRateLimitError, EsendyValidationError } from '@devstevenjs/esendy'

export async function POST(req: NextRequest) {
  const ip = req.headers.get('x-forwarded-for') ?? req.headers.get('x-real-ip') ?? 'unknown'

  try {
    const body = await req.json()
    const { name, email, message } = body

    // Basic presence check before hitting mailer validation
    if (!name || !email || !message) {
      return NextResponse.json({ error: 'All fields are required' }, { status: 400 })
    }

    await mailer.send({
      to: process.env.ESENDY_TO!,
      subject: `New contact from ${name}`,
      template: (
        <ContactTemplate
          senderName={name}
          senderEmail={email}
          message={message}
          // Template design — override project defaults here if needed
          siteName="My App"
          primaryColor="#2563eb"
          // extraFields={{ Phone: body.phone }}  // optional extra fields
        />
      ),
      rateLimitKey: ip,
    })

    return NextResponse.json({ success: true })
  } catch (err) {
    if (err instanceof EsendyRateLimitError) {
      return NextResponse.json(
        { error: 'Too many requests. Please wait and try again.' },
        { status: 429 }
      )
    }
    if (err instanceof EsendyValidationError) {
      return NextResponse.json({ error: err.message }, { status: 400 })
    }
    console.error('[esendy] send failed:', err)
    return NextResponse.json({ error: 'Failed to send message' }, { status: 500 })
  }
}

Next.js (Pages Router — API Route)

// pages/api/contact.ts
import type { NextApiRequest, NextApiResponse } from 'next'
import { mailer } from '@/lib/mailer'
import { ContactTemplate } from '@devstevenjs/esendy/templates'
import { EsendyRateLimitError, EsendyValidationError } from '@devstevenjs/esendy'

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') return res.status(405).end()

  const ip = (req.headers['x-forwarded-for'] as string) ?? req.socket.remoteAddress ?? 'unknown'

  try {
    const { name, email, message } = req.body

    if (!name || !email || !message) {
      return res.status(400).json({ error: 'All fields are required' })
    }

    await mailer.send({
      to: process.env.ESENDY_TO!,
      subject: `New contact from ${name}`,
      template: (
        <ContactTemplate
          senderName={name}
          senderEmail={email}
          message={message}
        />
      ),
      rateLimitKey: ip,
    })

    return res.status(200).json({ success: true })
  } catch (err) {
    if (err instanceof EsendyRateLimitError) return res.status(429).json({ error: 'Too many requests' })
    if (err instanceof EsendyValidationError) return res.status(400).json({ error: err.message })
    console.error('[esendy] send failed:', err)
    return res.status(500).json({ error: 'Failed to send message' })
  }
}

Vite + Express (or Fastify)

// server/routes/contact.ts  (Express example)
import { Router } from 'express'
import { mailer } from '../lib/mailer'
import { ContactTemplate } from '@devstevenjs/esendy/templates'
import { EsendyRateLimitError, EsendyValidationError } from '@devstevenjs/esendy'

const router = Router()

router.post('/contact', async (req, res) => {
  const ip = req.headers['x-forwarded-for'] as string ?? req.ip ?? 'unknown'

  try {
    const { name, email, message } = req.body

    if (!name || !email || !message) {
      return res.status(400).json({ error: 'All fields are required' })
    }

    await mailer.send({
      to: process.env.ESENDY_TO!,
      subject: `New contact from ${name}`,
      template: (
        <ContactTemplate
          senderName={name}
          senderEmail={email}
          message={message}
        />
      ),
      rateLimitKey: ip,
    })

    return res.json({ success: true })
  } catch (err) {
    if (err instanceof EsendyRateLimitError) return res.status(429).json({ error: 'Too many requests' })
    if (err instanceof EsendyValidationError) return res.status(400).json({ error: err.message })
    console.error('[esendy] send failed:', err)
    return res.status(500).json({ error: 'Failed to send message' })
  }
})

export default router

Usage: Server-Initiated Notifications

For event-driven emails (new user registered, order placed, etc.) you typically don't need rate limiting. Pass rateLimit: false in config, or don't pass rateLimitKey.

// lib/mailer.ts — separate instance for system emails (no rate limit)
export const systemMailer = createEsendy({
  smtp: { ... },
  from: { name: 'My App', address: process.env.ESENDY_SMTP_USER! },
  rateLimit: false,   // no rate limiting for server-initiated sends
  defaults: { siteName: 'My App', primaryColor: '#2563eb' },
})
// services/user.service.ts
import { systemMailer } from '@/lib/mailer'
import { NotificationTemplate } from '@devstevenjs/esendy/templates'

export async function sendWelcomeEmail(userEmail: string, userName: string) {
  await systemMailer.send({
    to: userEmail,
    subject: 'Welcome to My App',
    template: (
      <NotificationTemplate
        title={`Welcome, ${userName}!`}
        body="Your account has been created. You can now log in and get started."
        ctaText="Go to Dashboard"
        ctaUrl="https://yourdomain.com/dashboard"
      />
    ),
  })
}

export async function sendOrderConfirmation(userEmail: string, orderId: string) {
  await systemMailer.send({
    to: userEmail,
    subject: `Order #${orderId} confirmed`,
    template: (
      <NotificationTemplate
        title="Order Confirmed"
        body={`Your order #${orderId} has been received and is being processed.`}
        ctaText="View Order"
        ctaUrl={`https://yourdomain.com/orders/${orderId}`}
      />
    ),
  })
}

Templates

ContactTemplate

For contact form submissions. Displays sender name, email, message, and optional extra fields.

| Prop | Type | Required | Default | |------|------|----------|---------| | senderName | string | ✓ | — | | senderEmail | string | ✓ | — | | message | string | ✓ | — | | extraFields | Record<string, string> | | undefined | | siteName | string | | 'My Site' | | primaryColor | string | | '#2563eb' | | logoUrl | string | | undefined | | logoAlt | string | | siteName | | footerText | string | | '© {year} {siteName}' |

NotificationTemplate

For server-initiated event emails (welcome, order, password reset, etc.).

| Prop | Type | Required | Default | |------|------|----------|---------| | title | string | ✓ | — | | body | string \| ReactNode | ✓ | — | | previewText | string | | title | | ctaText | string | | undefined | | ctaUrl | string | | undefined | | siteName | string | | 'Notification' | | primaryColor | string | | '#2563eb' | | logoUrl | string | | undefined | | logoAlt | string | | siteName | | footerText | string | | '© {year} {siteName}' |

Layout

The base wrapper used by both templates. Use this to build custom templates that match the same design system.

import { Layout } from '@devstevenjs/esendy/templates'
import { Text, Button, Section } from '@react-email/components'

function MyCustomTemplate({ userEmail }: { userEmail: string }) {
  return (
    <Layout siteName="My App" primaryColor="#16a34a" previewText="Your account details">
      <Text>Your registered email is: {userEmail}</Text>
    </Layout>
  )
}

Telegram Delivery

When a telegram block is present on the mailer instance, esendy automatically posts one message to a single configured Telegram group after each successful send(). No per-send options needed — configure once, mirrors every send.

Setup

  1. Talk to @BotFather on Telegram → /newbot → copy the bot token.
  2. Add the bot to the target group. (For private groups, disable BotFather privacy mode, or @mention the bot once so it can post.)
  3. Get the chat ID: add @RawDataBot (or @getidsbot) to the group briefly, or call https://api.telegram.org/bot<token>/getUpdates after a message in the group and read result[].message.chat.id. Group IDs are negative (supergroups start with -100).
  4. Store both values in env vars: ESENDY_TELEGRAM_BOT_TOKEN and ESENDY_TELEGRAM_CHAT_ID.

Configuration

createEsendy({
  // ... smtp, from, rateLimit, defaults ...

  telegram: {
    botToken: process.env.ESENDY_TELEGRAM_BOT_TOKEN!,  // SECRET — never log
    chatId: process.env.ESENDY_TELEGRAM_CHAT_ID!,       // the ONE target group (negative for groups)
    enabled?: boolean,               // default: true when the block is present
    parseMode?: 'HTML' | 'MarkdownV2' | 'None',  // default: 'HTML'
    disableNotification?: boolean,   // default: false (silent push)
    failureMode?: 'silent' | 'throw', // default: 'silent'
    timeoutMs?: number,              // default: 10_000 (10s)
    apiBaseUrl?: string,             // default: 'https://api.telegram.org' (override for tests)
  },
})

Message format

The Telegram message is derived from the email subject and auto-generated plain-text body:

<b>Email subject</b>

Plain-text body of the email (HTML-escaped for parseMode: 'HTML')
  • parseMode: 'HTML' (default): interpolated content is HTML-escaped (&&amp;, <&lt;, >&gt;); the <b> subject wrapper is esendy's own markup.
  • parseMode: 'MarkdownV2': subject and body are escaped per Telegram's MarkdownV2 rules; subject is wrapped in *...*.
  • parseMode: 'None': raw text, no escaping, no parse_mode sent to Telegram.
  • Messages are capped at 4 096 characters (Telegram's limit); longer messages are truncated and end with .

Behavior & failure isolation

  • Email first. Telegram is only attempted after the email is successfully sent. Validation errors, rate-limit errors, and SMTP failures propagate exactly as in v1.0.x — Telegram is never called.
  • Silent by default. A Telegram failure does not fail your contact form. The email is already sent; send() returns with telegram: { ok: false, error: '...' }. Use failureMode: 'throw' if you need strict delivery guarantees (note: the email is already sent when the error is thrown).
  • No retries. One attempt per send (retry support is planned for v1.2).
  • Telegram's own rate limits. Telegram allows ~30 messages/second to different chats and ~1 message/second to the same group. For contact forms this is well within limits, but be aware if you are sending high volumes with systemMailer.
  • No new dependencies. The Telegram call uses the native fetch API (Node 18+, targeting Node 24).

Inspecting the result

send() returns an EsendySendResult with email and an optional telegram field:

const result = await mailer.send({ to, subject, template, rateLimitKey })

// Email outcome (always present)
result.email.messageId
result.email.accepted
result.email.rejected

// Telegram outcome (present only when a telegram block is configured)
result.telegram?.ok          // true on success
result.telegram?.messageId   // Telegram message_id on success
result.telegram?.skipped     // true when enabled: false
result.telegram?.error       // redacted error string on failure (silent mode)

Error handling

import { EsendyTelegramError } from '@devstevenjs/esendy'

try {
  await mailer.send({ ... })
} catch (err) {
  if (err instanceof EsendyTelegramError) {
    // Only thrown when failureMode: 'throw'
    // Note: the email was already sent when this error is raised
    err.status          // HTTP status from Telegram API (if any)
    err.telegramErrorCode  // error_code from Telegram response body (if any)
    // → return 502 or log and continue
  }
}

Configuration Reference

createEsendy({
  smtp: {
    host: string          // SMTP hostname
    port: number          // 587 (STARTTLS) or 465 (SSL)
    secure?: boolean      // auto-detected from port if omitted
    auth: {
      user: string
      pass: string
    }
  },

  from: string | { name: string; address: string }
  // string form:  '"My App" <[email protected]>'
  // object form:  { name: 'My App', address: '[email protected]' }

  rateLimit?: {
    windowMs?: number   // default: 60_000 (1 minute)
    max?: number        // default: 5 emails per window per key
  } | false             // false = disable rate limiting entirely

  defaults?: {
    siteName?: string
    primaryColor?: string   // hex color
    logoUrl?: string        // https URL only
    logoAlt?: string
    footerText?: string
  }

  // Optional — see "Telegram Delivery" section
  telegram?: {
    botToken: string                               // SECRET from @BotFather
    chatId: string | number                        // target group (negative for groups)
    enabled?: boolean                              // default: true
    parseMode?: 'HTML' | 'MarkdownV2' | 'None'    // default: 'HTML'
    disableNotification?: boolean                  // default: false
    failureMode?: 'silent' | 'throw'               // default: 'silent'
    timeoutMs?: number                             // default: 10_000
    apiBaseUrl?: string                            // default: 'https://api.telegram.org'
  }
})

Error Handling

Esendy throws typed errors you can catch and map to HTTP responses:

import { EsendyRateLimitError, EsendyValidationError, EsendyTelegramError } from '@devstevenjs/esendy'

try {
  await mailer.send({ ... })
} catch (err) {
  if (err instanceof EsendyRateLimitError) {
    // err.retryAfterMs — how long until the window resets
    // → return 429
  }
  if (err instanceof EsendyValidationError) {
    // Invalid email address, empty subject, too many recipients, etc.
    // → return 400
  }
  if (err instanceof EsendyTelegramError) {
    // Only thrown when telegram.failureMode is 'throw'
    // The email was already sent when this error is raised
    // err.status, err.telegramErrorCode
    // → return 502 or log and continue
  }
  // SMTP connectivity errors (wrong host, auth failed, etc.) are
  // plain Error instances from nodemailer → return 500
}

SMTP Health Check

Verify connectivity on application startup:

// Next.js: instrumentation.ts (App Router)
export async function register() {
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    const { mailer } = await import('./lib/mailer')
    await mailer.verify()
    console.log('[esendy] SMTP connection verified')
  }
}

// Express / Fastify: before server.listen()
await mailer.verify()

Graceful Shutdown

Call destroy() so the SMTP connection and cleanup timer close cleanly:

// Express
process.on('SIGTERM', () => {
  mailer.destroy()
  server.close()
})

// Next.js — not typically needed since Vercel/Node manages process lifecycle

Updating the Package

In consuming projects, update normally:

npm update @devstevenjs/esendy
# or to pin to a specific version
npm install @devstevenjs/[email protected]

Use a ^ range in package.json ("@devstevenjs/esendy": "^1.0.0") to receive minor and patch updates automatically via npm update, or pin to an exact version if you prefer explicit control.


Security Notes

  • reCAPTCHA / honeypot / bot protection — these belong on the contact form in each project, not in this package. Esendy handles the sending side only.
  • Never log the SMTP password — esendy disables nodemailer's built-in logger to prevent credential leaks.
  • TLS certificate verification is always enabled (rejectUnauthorized: true). Do not override this.
  • HTML from user input (contact form messages) is sanitized with sanitize-html before being embedded in templates, preventing XSS even if the email is viewed in a webmail client.
  • Rate limiting is in-memory and per-process. For multi-instance deployments (e.g. multiple Next.js replicas), consider a shared store (Redis) for rate limiting at the load balancer or API gateway level.
  • Telegram bot token is a secret — treat it like a password. Esendy never logs it; error messages and the result object are always redacted. Store it in an env var, never commit it.

For AI Coding Assistants

You can use this prompt to guide a coding assistant you use to implement this package.

I have a public npm package called `@devstevenjs/esendy` that I want to use for all email sending in the project. Before implementing any email functionality, read its full README here: <put npm url of the package here>

Key things to know before you start:
- Install with `npm install @devstevenjs/esendy`
- Two import paths: `from '@devstevenjs/esendy'` and `from '@devstevenjs/esendy/templates'`
- Never use nodemailer directly — esendy wraps it with TLS enforcement, header injection prevention, HTML sanitization, email validation, and rate limiting built in
- Always create two mailer instances in `lib/mailer.ts` (Next.js) or `src/lib/mailer.ts` (Vite): one called `mailer` with rateLimit for contact forms (keyed by client IP), one called `systemMailer` with `rateLimit: false` for server-initiated emails (welcome, order confirmation, etc.)
- Env vars follow the `ESENDY_` prefix convention: `ESENDY_SMTP_HOST`, `ESENDY_SMTP_PORT`, `ESENDY_SMTP_USER`, `ESENDY_SMTP_PASS`, `ESENDY_FROM_NAME`, `ESENDY_TO`
- Optional Telegram mirroring: add a single `telegram` block to `createEsendy` with `botToken` and `chatId`. When present, every successful `send()` posts one message to the configured group automatically — no per-send changes needed. Env vars: `ESENDY_TELEGRAM_BOT_TOKEN`, `ESENDY_TELEGRAM_CHAT_ID`. Gracefully no-ops when unset. Wire it on the `mailer` instance only (contact forms); `systemMailer` typically does not need it.
- Four error types to handle: `EsendyValidationError` → 400, `EsendyRateLimitError` → 429 (has `.retryAfterMs`), plain nodemailer `Error` → 500, `EsendyTelegramError` → only thrown when `telegram.failureMode: ‘throw’` (email already sent; usually log + continue)
- reCAPTCHA, honeypots, and bot protection not handled in the package, esendy handles sending only
- If this project is on React 18, use `--legacy-peer-deps` when installing

Now [replace the existing email logic / implement the contact form email / implement the notification emails] in this project using esendy.

In the last line you are supposed to choose a use-case.


Changelog

1.1.1

  • Fix: htmlToText now preserves newlines from block-level elements and <br> tags; previously collapsed everything to a single line in the plain-text email fallback and Telegram messages. Also decodes named HTML entities, decimal numeric entities (&#NNN;), and hex numeric entities (&#xHHH;) without any new dependency.

1.1.0

  • Add optional Telegram delivery: mirror each sent email to a single configured Telegram group via a bot (native fetch, no new dependencies). Configure once with a telegram block on createEsendy; one message is sent automatically on each successful send(). Email-first ordering; Telegram failures are silent by default and never block the email. New export: EsendyTelegramError. New types: TelegramConfig, TelegramDeliveryResult, EsendySendResult.

1.0.2

  • Added AI section in Readme to help those using AI coding assistants.

1.0.1

  • Fix: ESM build (dist/index.mjs) now resolves correctly on Node.js 24. The deep-path import validator/lib/isEmail was bundled without a .js extension, causing ERR_MODULE_NOT_FOUND under Node 24's strict ESM resolver. Switched to importing from the validator package root instead.

1.0.0

  • Initial release.