Email

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.

CloudSelf-HostedLinks
⚠️ Runtime-dependent✅ SupportedNPM 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

src/vitnode.api.config.ts
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.

Install the Nodemailer adapter
bun i @vitnode/nodemailer
pnpm i @vitnode/nodemailer
npm i @vitnode/nodemailer

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

Catch mail locally
docker run -p 8025:8025 -p 1025:1025 \
  -e MP_SMTP_AUTH_ACCEPT_ANY=1 \
  -e MP_SMTP_AUTH_ALLOW_INSECURE=1 \
  axllent/mailpit

Both 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:

src/vitnode.api.config.ts
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:

.env
NODE_MAILER_HOST=localhost
NODE_MAILER_PORT=1025
NODE_MAILER_USER=dev
NODE_MAILER_PASSWORD=dev
NODE_MAILER_FROM=hello@localhost

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

VariableMaps toExampleNotes
NODE_MAILER_HOSThostsmtp.host.comRequired
NODE_MAILER_USERuserapikeyRequired. Some providers use a fixed literal here
NODE_MAILER_PASSWORDpassword-Required
NODE_MAILER_FROMfromhi@you.comRequired. Address only
NODE_MAILER_PORTport587Optional - 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.

Next