Skip to content
All essays
WebJanuary 15, 202616 min

Building a Scalable Email System with Node.js

Complete guide to building a production-ready email system that handles transactional emails, marketing campaigns, and ensures high deliverability

Ü
Ümit Uz
Mobile & Full Stack Developer

Email remains one of the most effective communication channels for businesses. A robust email system handles transactional emails (password resets, order confirmations), marketing campaigns, and ensures messages actually reach inboxes. This guide covers building a production-ready email infrastructure using Node.js.

Understanding Email Infrastructure

A modern email system consists of several components:

SMTP Server: Sends emails to recipients Email Service Provider: Manages delivery, reputation, and scaling Template Engine: Generates dynamic email content Queue System: Handles high-volume sending asynchronously Analytics: Tracks opens, clicks, and deliverability Bounce Handling: Processes failed deliveries

Choosing an Email Service Provider

SendGrid (Twilio)

  • Excellent deliverability
  • 100 free emails/day
  • Good API and documentation
  • Real-time analytics

Amazon SES

  • Cost-effective for high volume
  • Pay-as-you-go pricing
  • AWS integration
  • Requires more setup

Mailgun

  • Developer-friendly API
  • 5,000 free emails/month
  • Good documentation
  • Advanced features

Postmark

  • Transactional email focused
  • Excellent deliverability
  • Fast processing
  • No marketing features

For this guide, we'll use SendGrid for its balance of features and ease of use.

Setup and Installation

bash
npm install @sendgrid/mail nodemailer
npm install -D @types/nodemailer

Basic Email Sending

Configuration

typescript
// lib/email.ts
import sgMail from '@sendgrid/mail';

sgMail.setApiKey(process.env.SENDGRID_API_KEY!);

interface EmailOptions {
  to: string | string[];
  from: string;
  subject: string;
  html?: string;
  text?: string;
  templateId?: string;
  dynamicTemplateData?: Record<string, any>;
}

export async function sendEmail(options: EmailOptions) {
  try {
    const msg: sgMail.MailDataRequired = {
      to: Array.isArray(options.to) ? options.to : [options.to],
      from: options.from,
      subject: options.subject,
    };

    if (options.templateId) {
      msg.templateId = options.templateId;
      msg.dynamicTemplateData = options.dynamicTemplateData;
    } else {
      if (options.html) msg.html = options.html;
      if (options.text) msg.text = options.text;
    }

    await sgMail.send(msg);

    return { success: true };
  } catch (error) {
    console.error('Email send failed:', error);
    return {
      success: false,
      error: error instanceof Error ? error.message : 'Unknown error',
    };
  }
}

Sending Plain Text Emails

typescript
// api/send-email.ts
export async function POST(req: Request) {
  const { to, subject, message } = await req.json();

  const result = await sendEmail({
    to,
    from: 'noreply@yourapp.com',
    subject,
    text: message,
  });

  if (result.success) {
    return Response.json({ success: true });
  }

  return Response.json({ error: result.error }, { status: 500 });
}

Email Templates

Inline HTML Templates

typescript
// lib/templates/welcome-email.ts
export function generateWelcomeEmail(userName: string) {
  return `
    <!DOCTYPE html>
    <html>
    <head>
      <meta charset="utf-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Welcome to Our App</title>
    </head>
    <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
      <div style="max-width: 600px; margin: 0 auto; padding: 20px;">
        <h1 style="color: #2563eb;">Welcome, ${userName}!</h1>
        <p>Thank you for signing up. We're excited to have you on board.</p>

        <div style="background: #f3f4f6; padding: 20px; border-radius: 8px; margin: 20px 0;">
          <h2 style="margin-top: 0;">Getting Started</h2>
          <ul>
            <li>Complete your profile</li>
            <li>Explore our features</li>
            <li>Connect with the community</li>
          </ul>
        </div>

        <p>
          <a href="${process.env.NEXT_PUBLIC_BASE_URL}/dashboard"
             style="background: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
            Get Started
          </a>
        </p>

        <hr style="margin: 30px 0; border: none; border-top: 1px solid #e5e7eb;">

        <p style="font-size: 12px; color: #6b7280;">
          You received this email because you signed up for Our App.
          <br>
          <a href="${process.env.NEXT_PUBLIC_BASE_URL}/unsubscribe">Unsubscribe</a>
        </p>
      </div>
    </body>
    </html>
  `;
}

Using Handlebars for Dynamic Templates

bash
npm install handlebars
typescript
// lib/template-engine.ts
import Handlebars from 'handlebars';

export function compileTemplate(template: string, data: Record<string, any>) {
  const compiledTemplate = Handlebars.compile(template);
  return compiledTemplate(data);
}

// Register helpers
Handlebars.registerHelper('eq', (a: any, b: any) => a === b);
Handlebars.registerHelper('formatDate', (date: Date) => {
  return new Date(date).toLocaleDateString();
});
Handlebars.registerHelper('json', (obj: any) => JSON.stringify(obj));

Transactional Emails

Welcome Email

typescript
// api/emails/welcome.ts
import { sendEmail } from '@/lib/email';
import { generateWelcomeEmail } from '@/lib/templates/welcome-email';

export async function sendWelcomeEmail(userEmail: string, userName: string) {
  return await sendEmail({
    to: userEmail,
    from: 'welcome@yourapp.com',
    subject: 'Welcome to Our App!',
    html: generateWelcomeEmail(userName),
  });
}

Password Reset Email

typescript
// api/emails/password-reset.ts
export function generatePasswordResetEmail(resetLink: string) {
  return `
    <!DOCTYPE html>
    <html>
    <body>
      <div style="max-width: 600px; margin: 0 auto; padding: 20px;">
        <h1>Reset Your Password</h1>
        <p>You requested to reset your password. Click the link below to create a new password:</p>

        <a href="${resetLink}"
           style="background: #dc2626; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px;">
          Reset Password
        </a>

        <p style="margin-top: 30px;">
          <strong>This link expires in 1 hour.</strong><br>
          If you didn't request this, please ignore this email.
        </p>
      </div>
    </body>
    </html>
  `;
}

export async function sendPasswordResetEmail(userEmail: string, resetToken: string) {
  const resetLink = `${process.env.NEXT_PUBLIC_BASE_URL}/reset-password?token=${resetToken}`;

  return await sendEmail({
    to: userEmail,
    from: 'security@yourapp.com',
    subject: 'Reset Your Password',
    html: generatePasswordResetEmail(resetLink),
  });
}

Order Confirmation Email

typescript
// api/emails/order-confirmation.ts
interface OrderItem {
  name: string;
  quantity: number;
  price: number;
}

interface OrderDetails {
  orderId: string;
  items: OrderItem[];
  total: number;
  shippingAddress: Address;
}

export function generateOrderConfirmationEmail(order: OrderDetails) {
  const itemsHtml = order.items
    .map(
      item => `
      <tr>
        <td style="padding: 12px 0; border-bottom: 1px solid #e5e7eb;">
          ${item.name}
        </td>
        <td style="padding: 12px 0; border-bottom: 1px solid #e5e7eb; text-align: center;">
          ${item.quantity}
        </td>
        <td style="padding: 12px 0; border-bottom: 1px solid #e5e7eb; text-align: right;">
          $${(item.price * item.quantity).toFixed(2)}
        </td>
      </tr>
    `
    )
    .join('');

  return `
    <!DOCTYPE html>
    <html>
    <body>
      <div style="max-width: 600px; margin: 0 auto; padding: 20px;">
        <h1>Order Confirmed!</h1>
        <p>Thank you for your order. Here are the details:</p>

        <h2>Order #${order.orderId}</h2>

        <table style="width: 100%; margin: 20px 0;">
          <thead>
            <tr style="border-bottom: 2px solid #374151;">
              <th style="text-align: left; padding: 12px 0;">Item</th>
              <th style="text-align: center; padding: 12px 0;">Quantity</th>
              <th style="text-align: right; padding: 12px 0;">Price</th>
            </tr>
          </thead>
          <tbody>
            ${itemsHtml}
          </tbody>
          <tfoot>
            <tr style="border-top: 2px solid #374151;">
              <td colspan="2" style="padding: 12px 0; text-align: right; font-weight: bold;">
                Total:
              </td>
              <td style="padding: 12px 0; text-align: right; font-weight: bold;">
                $${order.total.toFixed(2)}
              </td>
            </tr>
          </tfoot>
        </table>

        <h3>Shipping Address</h3>
        <p>
          ${order.shippingAddress.firstName} ${order.shippingAddress.lastName}<br>
          ${order.shippingAddress.addressLine1}<br>
          ${order.shippingAddress.city}, ${order.shippingAddress.state} ${order.shippingAddress.postalCode}
        </p>

        <p style="margin-top: 30px;">
          We'll send you another email when your order ships.
        </p>
      </div>
    </body>
    </html>
  `;
}

Queue System for High Volume

For sending emails at scale, use a queue system:

typescript
// lib/email-queue.ts
import { Queue } from 'bull';
import { sendEmail } from './email';

const emailQueue = new Queue('email', process.env.REDIS_URL!);

emailQueue.process(async (job) => {
  const { to, from, subject, html, text, templateId, dynamicTemplateData } = job.data;

  return await sendEmail({
    to,
    from,
    subject,
    html,
    text,
    templateId,
    dynamicTemplateData,
  });
});

export async function queueEmail(options: EmailOptions, delay = 0) {
  return await emailQueue.add(options, {
    delay,
    attempts: 3,
    backoff: {
      type: 'exponential',
      delay: 60000, // 1 minute
    },
  });
}

Handling Bounces and Complaints

Webhook Handler

typescript
// api/webhooks/sendgrid.ts
export async function POST(req: Request) {
  const events = await req.json();

  for (const event of events) {
    switch (event[0]) {
      case 'bounce':
        await handleBounce(event[1]);
        break;

      case 'spamreport':
        await handleSpamReport(event[1]);
        break;

      case 'delivered':
        await handleDelivered(event[1]);
        break;

      case 'open':
        await handleOpen(event[1]);
        break;

      case 'click':
        await handleClick(event[1]);
        break;
    }
  }

  return Response.json({ received: true });
}

async function handleBounce(event: any) {
  const { email, reason, status } = event;

  // Log bounce
  await db.emailBounces.create({
    data: {
      email,
      reason,
      status,
      timestamp: new Date(),
    },
  });

  // Update user status if too many bounces
  const bounceCount = await db.emailBounces.count({
    where: { email },
  });

  if (bounceCount >= 3) {
    await db.users.update({
      where: { email },
      data: { emailStatus: 'bounced' },
    });
  }
}

async function handleSpamReport(event: any) {
  const { email } = event;

  // Mark user as spam reporter
  await db.users.update({
    where: { email },
    data: { emailStatus: 'spam_reported' },
  });

  // Stop sending to this user
  await db.unsubscribeUsers.create({
    data: { email, reason: 'spam_report' },
  });
}

Email Analytics

Tracking Email Engagement

typescript
// lib/email-analytics.ts
export async function trackEmailOpen(messageId: string) {
  await db.emailAnalytics.create({
    data: {
      messageId,
      event: 'open',
      timestamp: new Date(),
    },
  });
}

export async function trackEmailClick(messageId: string, url: string) {
  await db.emailAnalytics.create({
    data: {
      messageId,
      event: 'click',
      url,
      timestamp: new Date(),
    },
  });
}

export async function getEmailStats(userId: string) {
  const emails = await db.email.findMany({
    where: { userId },
    include: {
      analytics: true,
    },
  });

  return {
    sent: emails.length,
    opened: emails.filter(e => e.analytics.some(a => a.event === 'open')).length,
    clicked: emails.filter(e => e.analytics.some(a => a.event === 'click')).length,
  };
}

Deliverability Best Practices

1. Authenticate Your Emails

Set up SPF, DKIM, and DMARC:

``txt ; SPF Record yourdomain.com. IN TXT "v=spf1 include:sendgrid.net -all"

; DKIM Record sendgrid._domainkey.yourdomain.com. IN TXT "k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA..."

; DMARC Record _dmarc.yourdomain.com. IN TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com"


### 2. Monitor Sender Reputation

// lib/sender-reputation.ts export async function checkSenderReputation() { const response = await fetch('https://api.sendgrid.com/v3/sender_reputation', { headers: { 'Authorization': Bearer ${process.env.SENDGRID_API_KEY}, }, });

const data = await response.json();

return { score: data.reputation_score, recommendations: data.recommendations, }; }


### 3. Warm Up New IPs

Gradually increase sending volume:

// lib/ip-warmup.ts const warmupSchedule = [ { day: 1, emailsPerDay: 50 }, { day: 2, emailsPerDay: 100 }, { day: 3, emailsPerDay: 200 }, { day: 4, emailsPerDay: 500 }, { day: 5, emailsPerDay: 1000 }, { day: 6, emailsPerDay: 2000 }, { day: 7, emailsPerDay: 5000 }, ];

export async function warmupIP() { for (const schedule of warmupSchedule) { console.log(Day ${schedule.day}: Sending ${schedule.emailsPerDay} emails); // Send emails gradually throughout the day await new Promise(resolve => setTimeout(resolve, 24 * 60 * 60 * 1000)); } }


## Testing

### Email Testing in Development

// lib/email-test.ts import { createTestAccount, createTransport, getTestMessageUrl } from 'nodemailer';

export async function setupTestEmail() { const testAccount = await createTestAccount();

const transporter = createTransport({ host: 'smtp.ethereal.email', port: 587, secure: false, auth: { user: testAccount.user, pass: testAccount.pass, }, });

return { transporter, testAccount }; }

// Usage in development if (process.env.NODE_ENV === 'development') { const { transporter, testAccount } = await setupTestEmail();

const info = await transporter.sendMail({ from: '"Test" <test@example.com>', to: 'recipient@example.com', subject: 'Test Email', html: '<b>Hello world?</b>', });

console.log('Preview URL: %s', getTestMessageUrl(info)); }


## Conclusion

Building a scalable email system requires careful planning and attention to deliverability. Key takeaways:

- **Choose the right ESP** for your needs
- **Use templates** for consistent branding
- **Implement queueing** for high volume
- **Handle bounces** to maintain reputation
- **Track analytics** to optimize performance
- **Authenticate emails** with SPF/DKIM/DMARC
- **Test thoroughly** before sending to customers

Remember: Email is a critical communication channel. Invest in building a robust system that scales with your business and keeps your customers engaged.
Enjoyed this essay? Share it.

Related essays

Next essay
Mastering Email Deliverability: Best Practices for Inbox Placement