Nodemailer (SMTP)
Deliver VitNode email over SMTP with the Nodemailer adapter - local mail catcher for development, any relay in production.
The Nodemailer adapter speaks plain SMTP, so it works with anything that has a host, a port and a login: your own Postfix, a provider's relay, or a mail catcher running on your laptop.
| Cloud | Self-Hosted | Links |
|---|---|---|
| ⚠️ Runtime-dependent | ✅ Supported | NPM Package · Nodemailer docs |
SMTP needs an outbound TCP connection, which edge runtimes cannot open and many serverless platforms block on the usual mail ports. If that is where you deploy, use Resend instead.
Quick start
import { buildApiConfig } from '@vitnode/core/vitnode.config'
import { NodemailerEmailAdapter } from '@vitnode/nodemailer'
export const vitNodeApiConfig = buildApiConfig({
email: {
adapter: NodemailerEmailAdapter({
from: process.env.NODE_MAILER_FROM,
host: process.env.NODE_MAILER_HOST,
user: process.env.NODE_MAILER_USER,
password: process.env.NODE_MAILER_PASSWORD,
}),
},
})port defaults to 587 and secure to false, which is the STARTTLS
combination almost every relay wants.
Setup
Install the adapter
nodemailer itself ships as a dependency of the adapter.
bun i @vitnode/nodemailerpnpm i @vitnode/nodemailernpm i @vitnode/nodemailerGet an SMTP server to talk to
In development, catch the mail locally instead of posting it to strangers.
Mailpit is one binary: an SMTP server on 1025
and a web inbox on 8025.
docker run -p 8025:8025 -p 1025:1025 \
-e MP_SMTP_AUTH_ACCEPT_ANY=1 \
-e MP_SMTP_AUTH_ALLOW_INSECURE=1 \
axllent/mailpitBoth flags matter: the adapter always authenticates, and it does so over an unencrypted local connection, which Mailpit refuses by default.
In production, take the four values from your provider's SMTP page - it is usually called "SMTP relay", "Sending" or "Integration".
Register the adapter
The adapter reads options, not the environment, so a port other than 587 has
to be passed through as well:
import { buildApiConfig } from '@vitnode/core/vitnode.config'
import { NodemailerEmailAdapter } from '@vitnode/nodemailer'
export const vitNodeApiConfig = buildApiConfig({
email: {
adapter: NodemailerEmailAdapter({
from: process.env.NODE_MAILER_FROM,
host: process.env.NODE_MAILER_HOST,
user: process.env.NODE_MAILER_USER,
password: process.env.NODE_MAILER_PASSWORD,
port: Number(process.env.NODE_MAILER_PORT ?? 587),
secure: process.env.NODE_MAILER_PORT === '465',
}),
},
metadata: {
title: 'My Community',
shortTitle: 'Community',
},
})Recipients see Community <hello@example.com>: the display name is
metadata.shortTitle ?? metadata.title, and from supplies only the address.
Set the environment variables
Mailpit accepts any credentials, so locally the username and password only have to be non-empty:
NODE_MAILER_HOST=localhost
NODE_MAILER_PORT=1025
NODE_MAILER_USER=dev
NODE_MAILER_PASSWORD=dev
NODE_MAILER_FROM=hello@localhostProduction is the same five lines with your provider's values and, on most
relays, NODE_MAILER_PORT=587.
Send a test mail
Send one from a route - the email overview has the
build() plus deliver() form that works from any plugin - then open
localhost:8025. The message appears in Mailpit's inbox,
where you can read the rendered HTML and the raw source that left your app.
Nothing there? deliver() throws inside the request, so the failing request is
where Nodemailer's own message is - in the response body while NODE_ENV is
development, in the log otherwise. Mail sent with the queued send() is a
tick behind instead, and its story is in the row:
select status, attempts, "lastError"
from core_queue
where name = 'send-email'
order by id desc
limit 1;Options
Prop
Type
Environment variables
The adapter takes values, not variable names, so these are yours to rename - just keep both sides in step.
| Variable | Maps to | Example | Notes |
|---|---|---|---|
NODE_MAILER_HOST | host | smtp.host.com | Required |
NODE_MAILER_USER | user | apikey | Required. Some providers use a fixed literal here |
NODE_MAILER_PASSWORD | password | - | Required |
NODE_MAILER_FROM | from | hi@you.com | Required. Address only |
NODE_MAILER_PORT | port | 587 | Optional - pass it through yourself, the adapter defaults to 587 |
Gotchas
Missing settings fail at delivery, not at boot
The adapter checks host, user, password and from inside sendEmail,
so a half-configured app still starts. With deliver() the failure is the
route's own 500, throwing Missing nodemailer configuration. With the queued
send() the request returns 200 and the same message turns up in
core_queue.lastError three attempts later.
Port 465 needs secure: true
Port 465 expects TLS immediately, while 587 negotiates it with STARTTLS.
Leaving secure at its default on 465 gives you a connection that hangs and
then times out rather than a clear error.
One connection per email
The transport is created inside sendEmail, so each message opens its own
SMTP connection - simple and stateless, and fine at the volumes a queue
drained once a minute produces. A relay that rate-limits connections rather
than messages is the case to watch.
Only the HTML part is sent
VitNode renders both an HTML and a plain-text version of every email, but this
adapter passes html alone.