Nodemailer Setup

beginner

Free email sending with Nodemailer. Supports SMTP, Gmail, and other providers.

emailnodemailersmtptransactional
Tested on201619TS5.9
$ bunx sinew add email/nodemailer
Interactive demo coming soon

1The Problem

Email services charge per email and add vendor lock-in:

  • Resend, SendGrid charge based on volume
  • API changes require code updates
  • No control over email infrastructure

2The Solution

Use Nodemailer with any SMTP provider for full control and zero per-email costs.

3Files

lib/email.ts

lib/email.tsTypeScript
import nodemailer from "nodemailer";

const transporter = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: parseInt(process.env.SMTP_PORT || "587"),
  secure: process.env.SMTP_SECURE === "true",
  auth: {
    user: process.env.SMTP_USER,
    pass: process.env.SMTP_PASSWORD,
  },
});

interface SendEmailOptions {
  to: string | string[];
  subject: string;
  html?: string;
  text?: string;
  from?: string;
  replyTo?: string;
  attachments?: Array<{
    filename: string;
    content: Buffer | string;
    contentType?: string;
  }>;
}

const DEFAULT_FROM = process.env.EMAIL_FROM || "noreply@example.com";

export async function sendEmail({
  to,
  subject,
  html,
  text,
  from = DEFAULT_FROM,
  replyTo,
  attachments,
}: SendEmailOptions) {
  const info = await transporter.sendMail({
    from,
    to: Array.isArray(to) ? to.join(", ") : to,
    subject,
    html,
    text,
    replyTo,
    attachments,
  });

  console.info(`Email sent: ${info.messageId}`);
  return info;
}

// Verify connection on startup
export async function verifyEmailConnection() {
  try {
    await transporter.verify();
    console.info("SMTP connection verified");
    return true;
  } catch (error) {
    console.error("SMTP connection failed:", error);
    return false;
  }
}

lib/email-templates.ts

lib/email-templates.tsTypeScript
interface WelcomeEmailProps {
  name: string;
  loginUrl: string;
}

export function welcomeEmailTemplate({ name, loginUrl }: WelcomeEmailProps) {
  return {
    subject: "Welcome to Our Platform!",
    html: `
      <!DOCTYPE html>
      <html>
        <head>
          <meta charset="utf-8">
          <meta name="viewport" content="width=device-width, initial-scale=1.0">
        </head>
        <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background-color: #f6f9fc; padding: 40px 20px;">
          <div style="max-width: 560px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; padding: 40px;">
            <h1 style="color: #1a1a1a; font-size: 24px; margin: 0 0 20px;">Welcome, ${name}!</h1>
            <p style="color: #4a4a4a; font-size: 16px; line-height: 24px;">
              We're excited to have you on board. Get started by exploring your dashboard.
            </p>
            <div style="text-align: center; margin: 30px 0;">
              <a href="${loginUrl}" style="display: inline-block; background-color: #e85a2c; color: #ffffff; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 600;">
                Go to Dashboard
              </a>
            </div>
            <p style="color: #8a8a8a; font-size: 14px;">
              If you have any questions, reply to this email.
            </p>
          </div>
        </body>
      </html>
    `,
    text: `Welcome, ${name}!\n\nWe're excited to have you on board. Get started by exploring your dashboard: ${loginUrl}`,
  };
}

interface PasswordResetProps {
  resetUrl: string;
  expiresIn: string;
}

export function passwordResetTemplate({ resetUrl, expiresIn }: PasswordResetProps) {
  return {
    subject: "Reset Your Password",
    html: `
      <!DOCTYPE html>
      <html>
        <head>
          <meta charset="utf-8">
          <meta name="viewport" content="width=device-width, initial-scale=1.0">
        </head>
        <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background-color: #f6f9fc; padding: 40px 20px;">
          <div style="max-width: 560px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; padding: 40px;">
            <h1 style="color: #1a1a1a; font-size: 24px; margin: 0 0 20px;">Reset Your Password</h1>
            <p style="color: #4a4a4a; font-size: 16px; line-height: 24px;">
              We received a request to reset your password. Click the button below to create a new password.
            </p>
            <div style="text-align: center; margin: 30px 0;">
              <a href="${resetUrl}" style="display: inline-block; background-color: #e85a2c; color: #ffffff; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 600;">
                Reset Password
              </a>
            </div>
            <p style="color: #4a4a4a; font-size: 14px;">
              This link will expire in ${expiresIn}. If you didn't request this, you can safely ignore this email.
            </p>
          </div>
        </body>
      </html>
    `,
    text: `Reset Your Password\n\nClick this link to reset your password: ${resetUrl}\n\nThis link expires in ${expiresIn}.`,
  };
}

app/api/auth/send-welcome/route.ts

app/api/auth/send-welcome/route.tsTypeScript
import { NextRequest, NextResponse } from "next/server";
import { sendEmail } from "@/lib/email";
import { welcomeEmailTemplate } from "@/lib/email-templates";

export async function POST(req: NextRequest) {
  const { email, name } = await req.json();

  try {
    const template = welcomeEmailTemplate({
      name,
      loginUrl: `${process.env.NEXTAUTH_URL}/login`,
    });

    await sendEmail({
      to: email,
      ...template,
    });

    return NextResponse.json({ success: true });
  } catch (error) {
    console.error("Failed to send welcome email:", error);
    return NextResponse.json({ error: "Failed to send email" }, { status: 500 });
  }
}

.env.example

.env.exampleBash
# SMTP Configuration
SMTP_HOST="smtp.gmail.com"
SMTP_PORT="587"
SMTP_SECURE="false"
SMTP_USER="your-email@gmail.com"
SMTP_PASSWORD="your-app-password"
EMAIL_FROM="Your App <your-email@gmail.com>"

4Dependencies

$ bun add nodemailer
$ bun add -D @types/nodemailer

5Configuration

Gmail Setup

  1. Enable 2-Factor Authentication on your Google account
  2. Generate an App Password at https://myaccount.google.com/apppasswords
  3. Use the app password as SMTP_PASSWORD

Other SMTP Providers

Amazon SES:

SMTP_HOST="email-smtp.us-east-1.amazonaws.com"
SMTP_PORT="587"
Bash

Mailgun:

SMTP_HOST="smtp.mailgun.org"
SMTP_PORT="587"
Bash

6Usage

Sending a Simple Email

import { sendEmail } from "@/lib/email";

await sendEmail({
  to: "user@example.com",
  subject: "Hello!",
  html: "<p>This is a test email</p>",
  text: "This is a test email",
});
TypeScript

Sending with Attachments

await sendEmail({
  to: "user@example.com",
  subject: "Your Invoice",
  html: "<p>Please find your invoice attached.</p>",
  attachments: [
    {
      filename: "invoice.pdf",
      content: pdfBuffer,
      contentType: "application/pdf",
    },
  ],
});
TypeScript

7Troubleshooting

Gmail blocking sign-ins

  • Use App Passwords instead of your account password
  • Enable "Less secure app access" is no longer supported

Emails going to spam

  • Set up SPF, DKIM, and DMARC records for your domain
  • Use a consistent "from" address
  • Avoid spam trigger words in subject lines

Related patterns