@devstevenjs/esendy
v1.0.2
Published
Secure, reusable email sender with React Email templates for Next.js and Vite projects
Downloads
286
Maintainers
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/esendyFor 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 routerUsage: 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 lifecycleUpdating 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-htmlbefore 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 importvalidator/lib/isEmailwas bundled without a.jsextension, causingERR_MODULE_NOT_FOUNDunder Node 24's strict ESM resolver. Switched to importing from thevalidatorpackage root instead.
1.0.0
- Initial release.
