DEV Community

Safal Bhandari
Safal Bhandari

Posted on

Building a Scalable Email Job Pipeline with Azure Service Bus and Node.js

Building a Scalable Email Job Pipeline with Azure Service Bus and Node.js

Sending transactional emails (verification, password reset, welcome messages) is a critical part of modern web applications. However, triggering these emails synchronously in your request handlers can slow down your API responses and introduce potential points of failure. Offloading email delivery to a background job queue not only speeds up your user-facing endpoints but also provides retry and dead‑lettering capabilities for robust error handling.

In this tutorial, we’ll walk through:

  1. Setting up environment variables, including your Azure Service Bus connection.
  2. Implementing an Email Service (using Nodemailer) to craft different email types (verification, reset, welcome).
  3. Enqueuing email jobs into an Azure Service Bus queue with a sendEmail() helper.
  4. Processing queued jobs in a background worker (sendEmailWorker()).
  5. Integrating the pipeline into your application.

By the end, you’ll have a reusable, production‑ready email job pipeline you can bookmark for future projects.


1. Prerequisites

  • Node.js v14+
  • An Azure Service Bus namespace and queue
  • An SMTP server (Gmail, SendGrid SMTP credentials, etc.)
  • Environment variables stored in a .env file or your deployment environment

Install the required npm packages:

npm install @azure/service-bus nodemailer dotenv
Enter fullscreen mode Exit fullscreen mode

2. Configuring Environment Variables

Create a .env file at your project root with:

# Azure Service Bus
SB_CONNECTION_STRING="Endpoint=sb://<your-namespace>.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=<your-key>"
QUEUE_NAME="email-jobs"

# SMTP (for example, Gmail or SendGrid)
SMTP_HOST="smtp.yourprovider.com"
SMTP_USER="smtp-username"
SMTP_PASS="smtp-password"
FROM_EMAIL="noreply@yourapp.com"

# Frontend URL (for links in emails)
FRONTEND_URL="https://yourapp.com"
Enter fullscreen mode Exit fullscreen mode

Tip: Never commit credentials to source control—use a secret manager or CI/CD environment variables.


3. Crafting Your Email Service

Let’s centralize all email templates and SMTP configuration in src/utils/emailService.ts:

import nodemailer from "nodemailer";

// Create and configure the transporter
const createTransporter = () =>
  nodemailer.createTransport({
    host: process.env.SMTP_HOST,
    port: 465,
    secure: true,
    auth: {
      user: process.env.SMTP_USER,
      pass: process.env.SMTP_PASS,
    },
  });

export const sendVerificationEmail = async (email: string, token: string) => {
  const transporter = createTransporter();
  const url = `${process.env.FRONTEND_URL}/verify-email/${token}`;

  const mailOptions = {
    from: process.env.FROM_EMAIL,
    to: email,
    subject: "Verify Your Email Address",
    html: `
      <div style="max-width:600px;margin:auto;padding:20px;font-family:Arial,sans-serif;">
        <h2 style="text-align:center;">Verify Your Email</h2>
        <p>Please confirm your address by clicking the button below:</p>
        <div style="text-align:center;margin:30px 0;">
          <a href="${url}" style="background:#007bff;color:#fff;padding:12px 24px;border-radius:4px;text-decoration:none;">
            Verify Email
          </a>
        </div>
        <p>If that doesn’t work, paste this link in your browser:</p>
        <p style="word-break:break-all;">${url}</p>
        <p style="color:#999;font-size:12px;">This link expires in 24 hours.</p>
      </div>
    `,
  };

  await transporter.sendMail(mailOptions);
};

export const sendPasswordResetEmail = async (email: string, token: string) => {
  const transporter = createTransporter();
  const url = `${process.env.FRONTEND_URL}/reset-password/${token}`;

  const mailOptions = {
    from: process.env.FROM_EMAIL,
    to: email,
    subject: "Reset Your Password",
    html: `
      <div style="max-width:600px;margin:auto;padding:20px;font-family:Arial,sans-serif;">
        <h2 style="text-align:center;">Reset Your Password</h2>
        <p>Click the button to choose a new password:</p>
        <div style="text-align:center;margin:30px 0;">
          <a href="${url}" style="background:#dc3545;color:#fff;padding:12px 24px;border-radius:4px;text-decoration:none;">
            Reset Password
          </a>
        </div>
        <p>If that doesn’t work, paste this link in your browser:</p>
        <p style="word-break:break-all;">${url}</p>
        <p style="color:#999;font-size:12px;">This link expires in 1 hour.</p>
      </div>
    `,
  };

  await transporter.sendMail(mailOptions);
};

export const sendWelcomeEmail = async (email: string, name: string) => {
  const transporter = createTransporter();
  const url = `${process.env.FRONTEND_URL}/dashboard`;

  const mailOptions = {
    from: process.env.FROM_EMAIL,
    to: email,
    subject: "Welcome to Our Platform!",
    html: `
      <div style="max-width:600px;margin:auto;padding:20px;font-family:Arial,sans-serif;">
        <h2 style="text-align:center;">Welcome, ${name || "there"}!</h2>
        <p>Your account is verified. Click below to get started:</p>
        <div style="text-align:center;margin:30px 0;">
          <a href="${url}" style="background:#28a745;color:#fff;padding:12px 24px;border-radius:4px;text-decoration:none;">
            Get Started
          </a>
        </div>
      </div>
    `,
  };

  await transporter.sendMail(mailOptions);
};
Enter fullscreen mode Exit fullscreen mode

Why separate service?

  • Keeps templates in one place for easy edits.
  • Decouples SMTP logic from queue handling.

4. Enqueuing Email Jobs

Create src/utils/sendEmailJob.js (or .ts) to push messages onto your Service Bus queue:

import { ServiceBusClient } from "@azure/service-bus";

const sbConnectionString = process.env.SB_CONNECTION_STRING!;
const queueName = process.env.QUEUE_NAME!;

export type emailJobMessageType = {
  type: "verification" | "reset" | "welcome";
  email?: string;
  token?: string;
  name?: string;
};

export const sendEmail = async (data: emailJobMessageType) => {
  if (!sbConnectionString || !queueName) {
    throw new Error("Service Bus not configured.");
  }

  const client = new ServiceBusClient(sbConnectionString);
  const sender = client.createSender(queueName);

  // Serialize and send
  await sender.sendMessages({
    body: JSON.stringify(data),
    contentType: "application/json",
    messageId: `${Date.now()}-${Math.random()}`,
  });

  await sender.close();
  await client.close();
};
Enter fullscreen mode Exit fullscreen mode

Usage Example in Your Route Handler

import { emailJobMessageType, sendEmail } from "../utils/sendEmailJob.js";

const emailJobData: emailJobMessageType = {
  type: "verification",
  email: userEmail,
  token: emailVerificationToken,
};

// Fire-and-forget—do not await in your HTTP handler
sendEmail(emailJobData).catch((err) =>
  console.error("Failed to queue email job:", err)
);
Enter fullscreen mode Exit fullscreen mode

5. Processing Jobs: The Email Worker

In a separate process (e.g., a dedicated worker service), subscribe to the queue and dispatch to our email service:

import { ServiceBusClient, ServiceBusReceivedMessage } from "@azure/service-bus";
import {
  sendVerificationEmail,
  sendPasswordResetEmail,
  sendWelcomeEmail,
} from "./emailService";

const sbConnectionString = process.env.SB_CONNECTION_STRING!;
const queueName = process.env.QUEUE_NAME!;

export const sendEmailWorker = async () => {
  const client = new ServiceBusClient(sbConnectionString);
  const receiver = client.createReceiver(queueName, { receiveMode: "peekLock" });

  console.log(`🚀 Email worker listening on "${queueName}"...`);

  receiver.subscribe({
    async processMessage(msg: ServiceBusReceivedMessage) {
      let job;
      try {
        job = JSON.parse(msg.body) as {
          type: string;
          email?: string;
          token?: string;
          name?: string;
        };
      } catch {
        console.error("Invalid message; dead-lettering");
        await receiver.deadLetterMessage(msg);
        return;
      }

      try {
        switch (job.type) {
          case "verification":
            await sendVerificationEmail(job.email!, job.token!);
            break;
          case "reset":
            await sendPasswordResetEmail(job.email!, job.token!);
            break;
          case "welcome":
            await sendWelcomeEmail(job.email!, job.name!);
            break;
          default:
            throw new Error("Unknown job type");
        }
        await receiver.completeMessage(msg);
        console.log(`✅ Processed ${job.type} email to ${job.email}`);
      } catch (err) {
        console.error("Error sending email:", err);
        // Optionally, abandon or dead‑letter depending on your retry policy
        await receiver.abandonMessage(msg);
      }
    },
    processError(err) {
      console.error("Receiver error:", err);
    },
  });
};
Enter fullscreen mode Exit fullscreen mode

Run this worker with:

node dist/utils/sendEmailJobWorker.js
Enter fullscreen mode Exit fullscreen mode

Or set it up as a separate service in your deployment pipeline.


6. Putting It All Together

  1. User signs up → Your API generates a verification token.
  2. Route handler calls sendEmail({ type: "verification", email, token }) without awaiting.
  3. Service Bus queues the message reliably.
  4. Background worker picks up the job, uses Nodemailer to send the email.
  5. API responds instantly, giving your users a snappy experience.

Benefits & Next Steps

  • Scalability: Easily scale out workers to handle high email volumes.
  • Resilience: Built‑in retries, dead‑lettering, and monitoring in Azure Service Bus.
  • Separation of Concerns: Your HTTP layer stays fast; email logic lives in its own service.

Next Steps:

  • Add logging/metrics (e.g., Application Insights) for visibility.
  • Implement retry policies or dead‑letter queue processing for persistent failures.
  • Secure your queue with role‑based access policies rather than the RootManageSharedAccessKey.
  • Extend the pipeline to handle SMS, push notifications, or other external jobs.

Bookmark this post for any future Node.js project that needs reliable, background‑processed emails. Happy coding!

Top comments (0)