@devstevenjs/esendy
v1.1.1
Published
Secure, reusable email sender with React Email templates for Next.js and Vite projects
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
- 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/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]
# Optional — Telegram delivery (see "Telegram delivery" section below)
ESENDY_TELEGRAM_BOT_TOKEN=123456:ABC-DEF...
ESENDY_TELEGRAM_CHAT_ID=-1001234567890Quick 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 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>
)
}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
- Talk to @BotFather on Telegram →
/newbot→ copy the bot token. - Add the bot to the target group. (For private groups, disable BotFather privacy mode, or @mention the bot once so it can post.)
- Get the chat ID: add @RawDataBot (or
@getidsbot) to the group briefly, or callhttps://api.telegram.org/bot<token>/getUpdatesafter a message in the group and readresult[].message.chat.id. Group IDs are negative (supergroups start with-100). - Store both values in env vars:
ESENDY_TELEGRAM_BOT_TOKENandESENDY_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 (&→&,<→<,>→>); 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 withtelegram: { ok: false, error: '...' }. UsefailureMode: '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
fetchAPI (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 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.
- 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:
htmlToTextnow 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 atelegramblock oncreateEsendy; one message is sent automatically on each successfulsend(). 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 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.
