Configuring Nodemailer with Gmail and SMTP Services

Nodemailer needs a transporter โ€” an object that knows how to connect to an email server and deliver messages. You create it once with your SMTP credentials and reuse it across every email you send. In this lesson you will install Nodemailer, configure a Gmail transporter using an App Password, set up an Ethereal Email transporter for development testing, and build the sendEmail utility function that every email feature in the MERN Blog will call.

Installation

cd server
npm install nodemailer

Environment Variables

# server/.env

# โ”€โ”€ Gmail (use App Password, not your real password) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
EMAIL_SERVICE=gmail
EMAIL_USER=your_gmail@gmail.com
EMAIL_PASS=xxxx xxxx xxxx xxxx    # 16-char App Password from Google Account settings
EMAIL_FROM="MERN Blog" <noreply@mernblog.com>

# โ”€โ”€ Alternative: SMTP service (SendGrid, Resend, Mailgun) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASS=SG.xxxxxxxxxxxxxxxx

# โ”€โ”€ App URLs (for links in emails) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
CLIENT_URL=http://localhost:5173
SERVER_URL=http://localhost:5000

# โ”€โ”€ To generate a Gmail App Password: โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Google Account โ†’ Security โ†’ 2-Step Verification โ†’ App passwords
# Select app: Mail, device: Other โ†’ type "MERN Blog" โ†’ Generate
Note: Gmail’s App Passwords require two-factor authentication to be enabled on your Google account. An App Password is a 16-character code that grants access to your Gmail account for a specific application. It can be revoked at any time without changing your real password. For production MERN applications, switch from Gmail (which throttles sending to ~500/day) to a dedicated transactional email service like SendGrid, Resend, or Postmark.
Tip: Ethereal Email (https://ethereal.email) is a disposable fake SMTP service specifically designed for Nodemailer testing. Every email your app sends in development is captured at ethereal.email without being delivered to any real inbox. Nodemailer provides a nodemailer.createTestAccount() function that generates a throwaway Ethereal account automatically โ€” you can view captured emails at the URL returned by nodemailer.getTestMessageUrl(info).
Warning: Do not create a new transporter on every email send. Creating a transporter opens an SMTP connection. Opening and closing that connection for every email is slow and can exhaust connection limits on Gmail and SMTP services. Create the transporter once at module load time and reuse it for every sendEmail call in the lifetime of the Express process.

The Email Utility Module

// server/src/utils/sendEmail.js
const nodemailer = require('nodemailer');

// โ”€โ”€ Create transporter once โ€” reused for all emails โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
let transporter;

const createTransporter = async () => {
  // Development: use Ethereal Email (fake SMTP โ€” captures without delivering)
  if (process.env.NODE_ENV === 'development') {
    const testAccount = await nodemailer.createTestAccount();
    transporter = nodemailer.createTransport({
      host:   'smtp.ethereal.email',
      port:    587,
      secure:  false,
      auth: {
        user: testAccount.user,
        pass: testAccount.pass,
      },
    });
    console.log('Ethereal test account created:', testAccount.user);
    return;
  }

  // Production: Gmail with App Password
  transporter = nodemailer.createTransport({
    service: 'gmail',
    auth: {
      user: process.env.EMAIL_USER,
      pass: process.env.EMAIL_PASS, // App Password โ€” NOT your real password
    },
  });
};

// Initialise once on module load
createTransporter().catch(console.error);

// โ”€โ”€ The sendEmail utility โ€” called by all email features โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const sendEmail = async ({ to, subject, text, html }) => {
  if (!transporter) {
    throw new Error('Email transporter not initialised');
  }

  const info = await transporter.sendMail({
    from:    process.env.EMAIL_FROM || '"MERN Blog" <noreply@mernblog.com>',
    to,
    subject,
    text,    // plain text fallback
    html,    // HTML version
  });

  // In development, log the Ethereal URL to view the captured email
  if (process.env.NODE_ENV === 'development') {
    console.log('Email preview URL:', nodemailer.getTestMessageUrl(info));
  }

  return info;
};

module.exports = sendEmail;

Using sendEmail in Controllers

// Usage example โ€” sending a simple email from a controller
const sendEmail = require('../utils/sendEmail');

const register = asyncHandler(async (req, res) => {
  const { name, email, password } = req.body;
  const user = await User.create({ name, email, password });

  const token = signAccessToken(user._id, user.role);
  res.status(201).json({ success: true, token, data: user });

  // Fire and forget โ€” send welcome email after responding
  sendEmail({
    to:      user.email,
    subject: 'Welcome to MERN Blog!',
    text:    `Hi ${user.name}, welcome to MERN Blog!`,
    html:    `<p>Hi <strong>${user.name}</strong>, welcome to MERN Blog!</p>`,
  }).catch(err => console.error('Welcome email failed:', err.message));
});

Testing with Ethereal in Development

Flow in development:
  1. POST /api/auth/register โ†’ user created โ†’ sendEmail() called
  2. Nodemailer connects to smtp.ethereal.email
  3. Email is "delivered" to Ethereal (captured โ€” NOT sent to real inbox)
  4. Console logs: "Email preview URL: https://ethereal.email/message/xxxx"
  5. Open the URL in browser โ€” see the formatted email exactly as it would appear
  6. No real email delivered โ€” safe to test with any email address

Flow in production (Gmail):
  1. Same sendEmail() call
  2. Nodemailer connects to Gmail SMTP
  3. Email delivered to recipient's real inbox
  4. No preview URL logged
// โ”€โ”€ Resend (recommended for production) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
transporter = nodemailer.createTransport({
  host: 'smtp.resend.com',
  port:  465,
  secure: true,
  auth: { user: 'resend', pass: process.env.RESEND_API_KEY },
});

// โ”€โ”€ SendGrid โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
transporter = nodemailer.createTransport({
  host: 'smtp.sendgrid.net',
  port:  587,
  auth: { user: 'apikey', pass: process.env.SENDGRID_API_KEY },
});

// โ”€โ”€ Mailgun โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
transporter = nodemailer.createTransport({
  host: 'smtp.mailgun.org',
  port:  587,
  auth: { user: process.env.MAILGUN_USER, pass: process.env.MAILGUN_PASS },
});

Common Mistakes

Mistake 1 โ€” Creating a new transporter on every sendEmail call

โŒ Wrong โ€” transporter recreated every time:

const sendEmail = async ({ to, subject, ... }) => {
  const transporter = nodemailer.createTransport({ ... }); // new connection every email!
  await transporter.sendMail({ ... });
};

โœ… Correct โ€” create once at module level and reuse:

// Created once โ€” module-level variable
const transporter = nodemailer.createTransport({ ... });
const sendEmail = async (options) => transporter.sendMail(options); // โœ“

Mistake 2 โ€” Using your real Gmail password

โŒ Wrong โ€” real password in .env:

EMAIL_PASS=myRealGmailPassword123
# If .env is ever committed or leaked โ€” full account access for attacker

โœ… Correct โ€” use a Gmail App Password (16 characters, generated for this app only).

Mistake 3 โ€” Not providing a plain text fallback

โŒ Wrong โ€” only HTML content:

transporter.sendMail({ to, subject, html: '<p>Welcome!</p>' });
// Some email clients or spam filters prefer or require plain text

โœ… Correct โ€” always include both:

transporter.sendMail({ to, subject, text: 'Welcome!', html: '<p>Welcome!</p>' }); // โœ“

Quick Reference

Task Code
Install npm install nodemailer
Dev transporter nodemailer.createTestAccount() โ†’ Ethereal SMTP config
Gmail transporter nodemailer.createTransport({ service: 'gmail', auth: { user, pass } })
Send email await transporter.sendMail({ from, to, subject, text, html })
View dev email nodemailer.getTestMessageUrl(info) โ†’ URL to preview
Fire-and-forget sendEmail({...}).catch(err => console.error(err))

🧠 Test Yourself

You set EMAIL_PASS=myActualGmailPassword in your .env file. The file is accidentally committed to a public GitHub repository. What is the immediate risk?