Skip to content
Back to Lumail Notes
11 min readtransactionalemail

How to Send Email with Node.js: A Step-by-Step Guide

Learn how to send email with Node.js using Nodemailer and modern APIs. Set up SMTP, build HTML templates, and ensure reliable inbox delivery.

How to Send Email with Node.js: A Step-by-Step Guide
In this guide

The simplest way to send email with Node.js is to use the Nodemailer library with your email provider's SMTP credentials, which takes only a few lines of code to get a message out the door. As your sending volume or deliverability needs grow, you can swap SMTP for a transactional email API without rewriting your application logic. This guide walks through both approaches, from a basic text email to HTML templates, attachments, and production-ready sending.

If you need to send email with Node.js, you have more than one path available, and picking the right one early saves you from a painful migration later. Whether you're sending a password reset link, a welcome message, or a weekly newsletter, the mechanics are similar: connect to a mail server or API, format the message, and hand it off for delivery. This guide covers the standard SMTP approach with Nodemailer, shows how to add HTML templates and attachments, and explains when it's time to move to a dedicated transactional email service.

Understanding Your Options to Send Email with Node.js

Before writing any code, it helps to understand the two broad strategies developers use to send email with Node.js: direct SMTP connections and HTTP-based transactional APIs. Both ultimately deliver a message to an inbox, but they differ in setup complexity, reliability at scale, and how much deliverability work falls on you.

SMTP (Simple Mail Transfer Protocol) is the original, open protocol for sending email. With a library like Nodemailer, your Node.js application connects directly to an SMTP server (your own mail server, a provider like Gmail or Microsoft 365, or a dedicated SMTP relay), authenticates with a username and password or app-specific token, and streams the message. This is the fastest way to get started and works well for low-volume use cases like contact forms or internal alerts.

Transactional email APIs replace the SMTP handshake with a simple HTTP request. Instead of managing a persistent connection, authentication tokens, and connection pooling yourself, you send a JSON payload to an endpoint and the service handles delivery, retries, and often bounce and complaint tracking. This matters once you're sending password resets, order confirmations, or marketing campaigns at meaningful volume, where deliverability monitoring becomes a real engineering concern.

CriteriaSMTP via NodemailerTransactional API
Setup timeMinutes, just need credentialsMinutes, needs an API key
ProtocolSMTP (persistent connection)HTTPS/REST
Best forLow volume, internal tools, prototypesProduction apps, campaigns, workflows
Deliverability visibilityLimited, you manage itTypically includes tracking and logs
Scaling effortGrows with connection managementHandled by the provider

There's no wrong starting point. Many teams begin with SMTP because it's familiar and requires no new account, then migrate once they need better observability or higher throughput. The good news is that the code you write for Nodemailer and the structure of a transactional API call are similar enough that switching later is a refactor, not a rewrite.

Step-by-Step: Sending Emails Using Nodemailer and SMTP

Nodemailer is the standard library developers reach for when they want to send email with Node.js over SMTP. It's a dependency-light module that handles the protocol details so you can focus on the message content. Here's how to go from zero to a sent email.

Step 1: Install Nodemailer

Create a Node.js project (or use an existing one) and install the package:

npm install nodemailer

Step 2: Create a Transporter

A "transporter" in Nodemailer represents the connection to your SMTP server. You'll need the host, port, and credentials for whichever provider you're using (your own mail server, a general-purpose email account, or an SMTP relay provided by an email service).

const nodemailer = require('nodemailer');

const transporter = nodemailer.createTransport({
  host: 'smtp.yourprovider.com',
  port: 587,
  secure: false, // true for port 465, false for other ports
  auth: {
    user: process.env.SMTP_USER,
    pass: process.env.SMTP_PASS,
  },
});

Keep credentials out of your source code. Store them in environment variables and load them with a package like dotenv during local development.

Step 3: Define the Message and Send It

Once the transporter is configured, sending a message is a single asynchronous call:

async function sendWelcomeEmail() {
  const info = await transporter.sendMail({
    from: '"Your App" <[email protected]>',
    to: '[email protected]',
    subject: 'Welcome aboard!',
    text: 'Thanks for signing up. We are glad to have you.',
  });

  console.log('Message sent:', info.messageId);
}

sendWelcomeEmail().catch(console.error);

Run the script with node yourfile.js. If the credentials and SMTP settings are correct, the message will leave your server and arrive in the recipient's inbox within seconds. This is the fastest route to send email with Node.js when you're testing an idea or building something small.

Step 4: Verify the Connection Before Sending

In production, it's worth checking that the transporter can actually reach the SMTP server before you try to send real messages. This catches configuration errors early:

transporter.verify((error) => {
  if (error) {
    console.error('SMTP connection failed:', error);
  } else {
    console.log('SMTP server is ready to send messages');
  }
});

A few common pitfalls to watch for at this stage: using the wrong port for your security setting (587 for STARTTLS, 465 for implicit TLS), forgetting that some providers require an app-specific password instead of your regular account password, and sending from a "from" address that isn't authorized on the domain, which can trigger spam filters or outright rejections.

Advanced Email Features: HTML Templates and Attachments

Plain text works for quick tests, but most real-world emails need formatting, branding, and sometimes file attachments. Nodemailer supports all of this natively.

Sending HTML Emails

Instead of (or alongside) the text field, pass an html field with your markup:

await transporter.sendMail({
  from: '"Your App" <[email protected]>',
  to: '[email protected]',
  subject: 'Your order is confirmed',
  text: 'Your order #1042 has been confirmed.',
  html: `
    <div style="font-family: sans-serif;">
      <h2>Order Confirmed</h2>
      <p>Thanks for your purchase. Order #1042 is on its way.</p>
    </div>
  `,
});

Always include a text fallback alongside your html version. Some email clients and spam filters still check for a plain-text alternative, and it improves accessibility for recipients using text-only readers.

Using a Templating Engine

Hardcoding HTML strings gets messy fast once you have more than one email type. A common pattern is to render a template file (with a library such as EJS or Handlebars) and pass the resulting string to the html field:

const ejs = require('ejs');
const path = require('path');

async function sendOrderConfirmation(order) {
  const html = await ejs.renderFile(
    path.join(__dirname, 'templates', 'order-confirmation.ejs'),
    { order }
  );

  await transporter.sendMail({
    from: '"Your App" <[email protected]>',
    to: order.customerEmail,
    subject: `Order #${order.id} confirmed`,
    html,
  });
}

This keeps your email markup in dedicated template files, which is easier to maintain and review than inline strings scattered through your codebase.

Adding Attachments

Nodemailer accepts an attachments array, supporting file paths, buffers, or remote URLs:

await transporter.sendMail({
  from: '"Your App" <[email protected]>',
  to: '[email protected]',
  subject: 'Your invoice',
  text: 'Please find your invoice attached.',
  attachments: [
    {
      filename: 'invoice-1042.pdf',
      path: './invoices/invoice-1042.pdf',
    },
  ],
});

For dynamically generated files (a PDF built at request time, for example), pass a Buffer instead of a file path. This avoids writing temporary files to disk.

Once you're comfortable sending HTML and attachments, it's worth reviewing broader guidance on how to send email from your code across different languages and frameworks, since many of the formatting and deliverability principles (correct headers, authenticated sending domains, text fallbacks) apply regardless of which stack you use.

Scaling Delivery: When to Move from SMTP to a Transactional API

Nodemailer and SMTP work well until volume, reliability, or visibility requirements outgrow a single server connection. A few signs it's time to reconsider your setup:

  • You're sending at meaningful volume. SMTP connections can become a bottleneck when your app needs to send many emails in a short window, since each connection has overhead and providers often rate-limit SMTP sending.
  • You need delivery visibility. With raw SMTP, you often only know a message left your server, not whether it bounced, was marked as spam, or was opened. Debugging deliverability issues without logs is slow.
  • You're mixing transactional and marketing email. Password resets and order confirmations have different sending patterns and reputation requirements than newsletters or campaign emails. Running both through the same SMTP account can hurt deliverability for both.
  • You need retries and queuing built in. Production systems need to handle temporary failures gracefully, and building that reliability layer yourself on top of SMTP takes real engineering time.

This is where a transactional email API becomes a practical alternative to raw SMTP for programmatic sending. Instead of managing a persistent SMTP connection, you make an authenticated HTTP call and let the service handle delivery, retries, and logging on its side. The structure of the call looks similar to what you already wrote with Nodemailer, just over HTTP instead of SMTP:

// Lumail TypeScript SDK (npm i lumail)
import { Lumail } from "lumail";

const lumail = new Lumail({ apiKey: process.env.LUMAIL_API_KEY! });

const { data, error } = await lumail.emails.send({
  from: '[email protected]',
  to: '[email protected]',
  subject: 'Your order is confirmed',
  html: '<p>Thanks for your purchase.</p>',
});

For teams already working in TypeScript, a typed, promise-based wrapper removes a lot of boilerplate around request formatting and error handling. The Lumail emails SDK is one example of this pattern: it wraps the REST API in a TypeScript-friendly client so sending transactional emails from a Node.js application reads like calling any other internal service, rather than hand-building HTTP requests.

The decision between SMTP and an API isn't permanent or exclusive. Many teams keep SMTP for low-stakes internal notifications while routing customer-facing transactional and campaign emails through an API with better monitoring. What matters is matching the sending method to the reliability and visibility the message actually needs.

SignalStay on SMTPConsider a transactional API
VolumeLow, occasional sendsRegular or growing send volume
Visibility needsMinimal logging acceptableNeed bounce/open/click data
Email typeInternal alerts, test scriptsTransactional + marketing campaigns
Team setupSolo developer, prototypeProduct team shipping to real users

Whichever path you choose first, the underlying skill transfers: once you understand how to send email with Node.js using a transporter and a message object, adapting that pattern to an API client is a small step rather than a complete rewrite.

Frequently asked questions

What is the best way to handle email sending errors in Node.js?

Handling errors requires wrapping your sending function in try, catch blocks or using callback error parameters. When using Nodemailer, always check the error object returned by the sendMail method. For production environments, implement a retry mechanism with exponential backoff and log failed attempts to an external monitoring service to ensure you do not miss critical delivery failures.

Can I send emails directly from Node.js without using an external SMTP server?

Yes, you can send emails directly by setting up a local mail transfer agent, or MTA, like Postfix on your server. However, this approach is highly discouraged for production environments. Most internet service providers and spam filters block direct emails from dynamic or unverified residential IP addresses, meaning your messages will likely end up in spam folders.

How do I secure SMTP credentials in a Node.js application?

Secure your SMTP credentials by storing them in environment variables rather than hardcoding them into your source code. Use a package like dotenv during development to load these variables from a local file. In production, inject these secrets using your hosting platform settings or a dedicated secrets manager to prevent unauthorized access or accidental exposure on public repositories.

What is the difference between Nodemailer and a transactional email API in Node.js?

Nodemailer is a flexible Node.js library that connects to any standard SMTP server to deliver messages. A transactional email API, offered by dedicated cloud providers, handles delivery through optimized HTTP endpoints. While Nodemailer is excellent for standard setups, APIs provide superior delivery speeds, detailed analytics, easier template management, and better IP reputation control without managing the underlying mail server infrastructure.

How do you send bulk emails efficiently in Node.js?

Sending bulk emails efficiently requires using connection pooling to reuse SMTP connections instead of opening a new one for every message. You should also queue your messages using a background task runner like BullMQ or Kue paired with Redis. This prevents your main Node.js process from blocking and allows you to rate limit your outbound email rate to comply with provider limits.