@vorshim92/mailer
v0.2.0
Published
Reusable, secure SMTP/nodemailer toolkit for Node & Next.js: core sender + contact-form profile.
Maintainers
Readme
@vorshim92/mailer
Reusable, secure SMTP toolkit for Node & Next.js. A framework-agnostic core (nodemailer transport singleton, discriminated SendResult, dev transports) plus a contact-form profile (validation, honeypot, rate-limit, dual-email).
Install
npm install @vorshim92/mailer nodemailer zod
# optional, only if you use the /react renderer:
npm install react @react-email/render @react-email/componentsNext.js consumers: add serverExternalPackages: ['nodemailer'] to next.config.ts.
Core
import { getMailer } from '@vorshim92/mailer';
const mailer = await getMailer(); // reads SMTP_* env, memoized per process
const res = await mailer.send({ to: '[email protected]', subject: 'Hi', html: '<p>Hello</p>', text: 'Hello' });
if (!res.ok) console.error(res.reason);Env vars: SMTP_HOST, SMTP_PORT, SMTP_SECURE, SMTP_USER, SMTP_PASSWORD, MAIL_FROM.
When SMTP_PORT/SMTP_SECURE are omitted the default is implicit TLS on 465; set SMTP_SECURE=false to get STARTTLS on 587.
Presets (fill host/port/secure): loadMailEnv({ preset: 'aruba' | 'brevo' | 'gmail' }) from @vorshim92/mailer/env.
Set MAIL_PREVIEW_MODE=true to make getMailer() log emails instead of sending them (dev/preview). In production each preview send logs a loud console.warn so a forgotten flag is visible.
getMailer() is a process-wide singleton: options are honored only on the first call; later calls reuse the first instance (a dev-time warning fires if they differ). Use createMailer() for a differently-configured mailer.
Security defaults
- File & URL access disabled — messages cannot read local files (
attachments[].path) or fetch remote URLs. Opt out per transport withallowFileAccess: true/allowUrlAccess: trueif you send filesystem attachments you control. - STARTTLS enforced — with
secure: falsethe transport setsrequireTLS, so a MITM stripping STARTTLS aborts the connection instead of leaking credentials in plaintext. For TLS-less local testing use the{ dev: 'console' }transport. - TLS ≥ 1.2, certificate validation always on.
- Retry note — with
retryconfigured,connection/timeouterrors are retried; a timed-out attempt may still have been delivered, so duplicates are possible. Leaveretryunset for strictly-once sends.
Contact form (Next.js server action)
The example contains JSX, so the file must be
.tsx(e.g.app/actions.tsx).
'use server';
import { headers } from 'next/headers';
import { getMailer } from '@vorshim92/mailer';
import { renderEmail } from '@vorshim92/mailer/react';
import {
handleContactSubmission,
getClientIp,
InMemoryFixedWindow,
baseContactSchema,
} from '@vorshim92/mailer/contact-form';
import { Notification } from './emails/notification';
const store = new InMemoryFixedWindow();
export async function submitContact(formData: unknown) {
const mailer = await getMailer();
return handleContactSubmission({
mailer,
input: formData,
schema: baseContactSchema,
clientIp: getClientIp(await headers(), { trust: 'x-real-hop' }),
store,
rateLimit: { perIp: { max: 5, windowMs: 600_000 }, global: { max: 40, windowMs: 600_000 } },
notify: {
to: '[email protected]',
subject: 'Nuovo contatto',
body: (d) => renderEmail(<Notification {...d} />),
},
confirm: {
subject: 'Grazie per averci contattato',
body: (d) => renderEmail(<Notification {...d} />),
},
});
}getClientIp trust modes: 'x-real-hop' (Nginx/VPS — recommended), 'last-xff', 'first-xff' (spoofable), 'direct' (no proxy: never reads headers, pass the connection address — getClientIp(headers, { trust: 'direct', socketAddress: req.socket.remoteAddress }); not available in Next.js server actions, use 'x-real-hop' there). Every mode validates the result with net.isIP and returns null for anything that isn't a bare IP literal.
Rate limiting is best-effort by design: if the store errors at runtime the check fails open (the submission proceeds and the error is logged). InMemoryFixedWindow caps its memory at 10 000 buckets by default (new InMemoryFixedWindow({ maxEntries }) to tune) and, being a fixed window, allows up to 2× max requests in a span straddling two windows.
Dev / test
import { createMailer } from '@vorshim92/mailer';
const mailer = await createMailer({ transport: { dev: 'console' } }); // logs JSON, no network
// or { dev: 'ethereal' } for a throwaway preview inboxScope
v1 ships the core + contact-form profile. Transactional templates and a self-hosted VPS relay are planned (see docs/design.md).
