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

Published

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

Downloads

286

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

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

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

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

Error Handling

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

import { EsendyRateLimitError, EsendyValidationError } 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
  }
  // 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.

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`
- Three error types to handle: `EsendyValidationError` → 400, `EsendyRateLimitError` → 429 (has `.retryAfterMs`), plain nodemailer `Error` → 500
- 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.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.