Dev Life

Building transactional email workflows for shipping notifications with Mailgun’s API

Customers expect real-time updates the moment their order moves. This guide walks you through building a reliable transactional email workflow for shipping notifications using Mailgun’s API and BullMQ, so your emails hit the inbox exactly when they should.
Image for Building transactional email workflows for shipping notifications with Mailgun’s API
June 11, 2025

When you order something online, you expect to know exactly where it is and when it will arrive. Transactional email workflows help make this type of timely communication possible.

Unlike marketing emails that promote products, transactional emails are sent in response to a user’s action, like placing an order or getting a shipping update. They provide real-time details that keep users in the loop every step of the way.

In this article, you’ll learn how to build a transactional email workflow for shipping notifications using Mailgun’s API. The workflow will incorporate BullMQ, a powerful job queue system, to queue and process email notifications separately from the main application logic and improve scalability and performance. This is useful for high-traffic applications where sending emails directly within a request-response cycle could slow down the system.

Implementing transactional email workflows for shipping notifications with Mailgun’s API

To follow along, you need these tools and accounts:

Before diving into the implementation, let’s take a look at the workflow:

transactional email workflows diagram

Here’s how the process works:

  • The shipping team (or anyone managing shipping updates) sends an HTTP request to update the order status
  • The Express server updates the order status in the SQLite database and queues a job in BullMQ
  • A BullMQ worker processes jobs from the queue and interacts with the Mailgun API to send an email to the customer
  • The worker automatically retries failed jobs
  • Mailgun delivers the email to the customer

Mailgun account setup

To use the Mailgun API, you need a Mailgun domain and an API key for authentication. Mailgun provides two types of domains: sandbox domains for testing and custom domains for use in production environments.

In this guide, you’ll use a sandbox domain. Each Mailgun account is automatically provisioned with one, so you don’t have to create it manually. To obtain your sandbox domain, log in to your Mailgun account, then navigate to Send > Sending > Domains in the sidebar. Copy your domain:

Send > Sending > Domains Screen Image

By default, a sandbox domain can only send emails to authorized recipients. To set up an email as an authorized recipient, click your domain name on the Domains page to view its settings. On the Setup tab, under Add authorized recipients, add the email address that you want to send emails to. Make sure to follow the instructions sent to the email address you provided to set it up as an authorized recipient:

Add authorized recipients Screen Image

To obtain your API key, navigate to the API Keys page and click Add new key to create a new API key. Provide a name for the key and click Create Key, then make sure to copy the value of your key because it’ll only be displayed once:

API Keys page Screen Image

Setting up the starter template

To keep this tutorial focused on building transactional email workflows, we’ve prepared a starter template for you to build on. Clone the template to your local machine by running the following command in your terminal:

git clone --single-branch -b starter-template https://github.com/kimanikevin254/mailgun-transactional-email-workflows.git

Here are the key files in the project:

  • src/controllers/order.controller.ts contains two methods:
    • index renders the admin UI, which is defined in src/views/admin.ejs
    • updateOrderStatus updates the order status in the database
  • src/database/entity/order.entity.ts defines the order entity
  • src/database/entity/user.entity.ts defines the user entity
  • src/database/seed.ts seeds the database with a sample user and order
  • docker-compose.yml runs Redis in Docker, which is required by BullMQ

Install all the dependencies using the following command:

npm install

Next, rename the .env.example file to .env and replace the placeholder values for MAILGUN_DOMAIN and MAILGUN_API_KEY with the credentials you obtained from the Mailgun dashboard. For the MAIL_FROM variable, provide your email address as the value.

Next, open the src/database/seed.ts file and replace the placeholder details in the userInfo constant with your details. For the email, make sure to provide one that you added as an authorized recipient.

Finally, seed the database by executing the command npm run db:seed in your terminal. You can now run the project using the command npm run dev and navigate to http://localhost:3000 in your browser to view and update the order status:

order status management screen image

In the next section, you’ll implement a workflow that automatically sends an email to the user once the order status is updated.

Designing the email templates

Well-designed email templates improve readability, enhance user experience, and ensure your emails look professional across different devices. In this section, you’ll create templates that make your transactional emails clear, engaging, and effective.

An order goes through three states (shipped, out for delivery, and delivered), as defined in the OrderStatus type in src/types/index.ts. You’ll create a corresponding email template for each state to keep customers informed at every step.

To do this, create a file at src/email-templates/html/shipped.html and add the following code:

                            

                                <!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8" />
    <title>Order Shipped</title>
</head>
<body style="font-family: Arial, sans-serif; background-color: #f4f4f4; padding: 20px; text-align: center;">

    <div style="background: #ffffff; padding: 20px; border-radius: 8px; max-width: 600px; margin: auto; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);">

        <div style="background-color: #007bff; color: white; padding: 15px; font-size: 22px; font-weight: bold; border-top-left-radius: 8px; border-top-right-radius: 8px;">
            Your Order Has Been Shipped! ✈️
        </div>

        <div style="padding: 20px; font-size: 16px; color: #333; text-align: left;">
            <p style="margin: 0 0 10px;">Hello, <%= name %></p>
            <p style="margin: 0 0 10px;">Your order has been shipped and is on its way to you. 📦</p>
            <p style="margin: 0 0 10px;"><strong>Tracking Number:</strong> <%= trackingNumber %></p>
            <p style="margin: 0 0 10px;">You can track your package below. We hope you enjoy your purchase!</p>

            <a href="https://yourwebsite.com/track?trackingNumber=<%= trackingNumber %>"
               style="background-color: #007bff; color: white; text-decoration: none; padding: 12px 18px; border-radius: 5px; display: inline-block; margin-top: 15px; font-weight: bold;">
                Track Your Shipment
            </a>
        </div>

        <div style="background-color: #f4f4f4; padding: 10px; font-size: 14px; color: #555; border-bottom-left-radius: 8px; border-bottom-right-radius: 8px;">
            Thank you for shopping with us! 🛒
        </div>
    </div>
</body>
</html>

                            
                        

This email template is used to notify customers that their order has been shipped. It includes placeholders for the customer’s name and tracking number, which will be dynamically filled by EJS when rendering the template. The templates for the other two order statuses will follow the same format.

Create a template to notify users that their order is out for delivery by creating a file named out_for_delivery.html in the src/email-templates/html folder and adding the following code:

                            

                                <!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8" />
    <title>Out for Delivery</title>
</head>
<body style="font-family: Arial, sans-serif; background-color: #f4f4f4; padding: 20px; text-align: center;">

    <div style="background: #ffffff; padding: 20px; border-radius: 8px; max-width: 600px; margin: auto; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);">

        <div style="background-color: #28a745; color: white; padding: 15px; font-size: 22px; font-weight: bold; border-top-left-radius: 8px; border-top-right-radius: 8px;">
            Your Order Is Out for Delivery! 🚚
        </div>

        <div style="padding: 20px; font-size: 16px; color: #333; text-align: left;">
            <p style="margin: 0 0 10px;">Hello, <%= name %></p>
            <p style="margin: 0 0 10px;">Good news! Your package is out for delivery and will arrive soon. 📦</p>
            <p style="margin: 0 0 10px;"><strong>Tracking Number:</strong> <%= trackingNumber %></p>
            <p style="margin: 0 0 10px;">We appreciate your business and can't wait for you to receive your order!</p>

            <a href="https://yourwebsite.com/track?trackingNumber=<%= trackingNumber %>"
               style="background-color: #28a745; color: white; text-decoration: none; padding: 12px 18px; border-radius: 5px; display: inline-block; margin-top: 15px; font-weight: bold;">
                Track Your Package
            </a>
        </div>

        <div style="background-color: #f4f4f4; padding: 10px; font-size: 14px; color: #555; border-bottom-left-radius: 8px; border-bottom-right-radius: 8px;">
            Thank you for choosing us! 🛍️
        </div>
    </div>
</body>
</html>
                            
                        

Lastly, create a template to notify users that their order has been delivered by creating a file named delivered.html in the src/email-templates/html folder and adding the following code:

                            

                                <!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8" />
    <title>Order Delivered</title>
</head>
<body style="font-family: Arial, sans-serif; background-color: #f4f4f4; padding: 20px; text-align: center;">

    <div style="background: #ffffff; padding: 20px; border-radius: 8px; max-width: 600px; margin: auto; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);">

        <div style="background-color: #ffc107; color: white; padding: 15px; font-size: 22px; font-weight: bold; border-top-left-radius: 8px; border-top-right-radius: 8px;">
            Your Order Has Been Delivered! 🎉
        </div>

        <div style="padding: 20px; font-size: 16px; color: #333; text-align: left;">
            <p style="margin: 0 0 10px;">Hello, <%= name %></p>
            <p style="margin: 0 0 10px;">We're happy to inform you that your package has been successfully delivered. 🎁</p>
            <p style="margin: 0 0 10px;"><strong>Tracking Number:</strong> <%= trackingNumber %></p>
            <p style="margin: 0 0 10px;">We hope you enjoy your purchase! If you have any questions, feel free to contact us.</p>

            <a href="https://yourwebsite.com/track?trackingNumber=<%= trackingNumber %>"
               style="background-color: #ffc107; color: white; text-decoration: none; padding: 12px 18px; border-radius: 5px; display: inline-block; margin-top: 15px; font-weight: bold;">
                Track Your Order
            </a>
        </div>

        <div style="background-color: #f4f4f4; padding: 10px; font-size: 14px; color: #555; border-bottom-left-radius: 8px; border-bottom-right-radius: 8px;">
            Thank you for shopping with us! 🛍️
        </div>
    </div>
</body>
</html>
                            
                        

To manage and render email templates dynamically, you need to create a class that loads and organizes templates for different order statuses. This class will retrieve the right template and render it with customer-specific data. To do this, create a new file named index.ts in the src/email-templates folder and add the following code:

                            

                                import { readFileSync } from "fs";
import { EmailTemplate, OrderStatus } from "../types";
import ejs from "ejs";

export class EmailTemplateManager {
    private templates: Map<OrderStatus, EmailTemplate>;

    constructor() {
        this.templates = new Map();
        this.loadTemplates();
    }

    private loadTemplates() {
        // Load each template for different statuses
        const statuses: OrderStatus[] = [
            "shipped",
            "out_for_delivery",
            "delivered",
        ];
        const subjects: Record<OrderStatus, string> = {
            shipped: "Your Order Has Been Shipped! ✈️",
            out_for_delivery: "Your Order is Out for Delivery! 🚚",
            delivered: "Your Order Has Been Delivered! 🎉",
        };

        statuses.forEach((status) => {
            const htmlTemplate = readFileSync(
                `src/email-templates/html/${status}.html`,
                "utf-8"
            );

            this.templates.set(status, {
                subject: subjects[status],
                html: htmlTemplate,
            });
        });
    }

    getTemplate(status: OrderStatus): EmailTemplate {
        const template = this.templates.get(status);
        if (!template) {
            throw new Error(`No template found for status: ${status}`);
        }
        return template;
    }

    renderTemplate(templateString: string, data: Record<string, any>): string {
        return ejs.render(templateString, data); // Render the template with the provided data
    }
}
                            
                        

This class initializes a private variable named templates that stores email templates in a Map for quick lookup based on order status. Using a Map ensures efficient retrieval and avoids repeatedly reading files from disk. The class also defines the following methods:

  • loadTemplates (private) reads HTML templates from files and stores them in the templates Map, along with their corresponding subject lines.
  • getTemplate retrieves the email template for a given order status and throws an error if no template is found.
  • renderTemplate uses EJS to dynamically render a template with provided data.

Implementing the email-sending functionality

With the email templates set up, the next step is to implement the functionality for sending notifications. This involves queuing email jobs and processing them asynchronously.

But before you can do this, you need to install a few dependencies by executing the following command:

npm i mailgun.js@11.1.0 form-data@4.0.2 bullmq@5.41.2 ioredis@5.5.0

Here’s an overview of each of these dependencies:

  • mailgun.js is a Node.js client for interacting with the Mailgun API. You’ll use this to send transactional emails.
  • form-data is a package required by mailgun.js to handle form submissions when sending emails.
  • bullmq is a job queue library for handling asynchronous tasks. This allows you to queue email notifications and process them in the background.
  • ioredis is a Redis client for Node.js used by BullMQ to store and manage queued jobs.

Next, you need to define a Mailgun API client that you’ll use to send emails. To do this, create a new file at src/config/mailgun.config.ts and add the following code:

                            

                                import FormData from "form-data";
import Mailgun from "mailgun.js";

const mailgun = new Mailgun(FormData);
export const mgClient = mailgun.client({
    username: "api",
    key: process.env.MAILGUN_API_KEY,
});
                            
                        

This code sets up and exports a Mailgun client using the mailgun.js library and form-data, allowing your application to send emails through the Mailgun API with the API key stored in environment variables.

Now, you need to define a Redis connection that will be used by BullMQ to store and manage background jobs. To do this, create a new file named redis.config.ts in the src/config folder and add the code below:

                            

                                import IORedis from "ioredis";

export const redisConfig = new IORedis({
    host: process.env.REDIS_HOST,
    port: parseInt(process.env.REDIS_PORT),
    maxRetriesPerRequest: null,
});
                            
                        

You need to set up a BullMQ queue for the shipping notifications that will handle background email notification jobs using the Redis connection you defined in the previous step. To do this, create a new file named queues.config.ts in the src/config folder and add the following code:

                            

                                import { Queue } from "bullmq";
import { redisConfig } from "./redis.config";

export const QUEUE_NAMES = {
    SHIPPING_NOTICATIONS: "shipping-notifications",
};

export const JOB_NAMES = {
    SEND_NOTIFICATION: "send-notification",
};

export const SHIPPING_NOTICATIONS_QUEUE = new Queue(
    QUEUE_NAMES.SHIPPING_NOTICATIONS,
    {
        connection: redisConfig,
    }
);
                            
                        

This code also defines names for the queue and job type that can be used in other parts of the application to avoid hard-coded strings.

Lastly, create a new file at src/services/notifications.service.ts and add the following code:

                            

                                import { Job, Queue, Worker } from "bullmq";
import {
    QUEUE_NAMES,
    SHIPPING_NOTICATIONS_QUEUE,
    JOB_NAMES,
} from "../config/queues.config";
import { redisConfig } from "../config/redis.config";
import { mgClient } from "../config/mailgun.config";
import { IMailgunClient } from "mailgun.js/Interfaces";
import { EmailTemplateManager } from "../email-templates";
import { MailgunMessageData } from "mailgun.js";
import { NotificationJob } from "../types";

export class NotificationService {
    private queue: Queue;
    private mailgunClient: IMailgunClient;
    private templateManager: EmailTemplateManager;

    constructor() {
        this.queue = SHIPPING_NOTICATIONS_QUEUE;
        this.mailgunClient = mgClient;
        this.templateManager = new EmailTemplateManager();
        this.setupWorker();
    }

    private setupWorker() {
        const worker = new Worker(
            QUEUE_NAMES.SHIPPING_NOTICATIONS,
            async (job) => {
                console.log(`Processing job: ${job.id}`);
                await this.processNotification(job);
            },
            {
                connection: redisConfig,
            }
        );

        worker.on("completed", (job) => {
            console.log(`Job ${job.id} completed successfully`);
        });

        worker.on("failed", (job, error) => {
            console.log(`Job ${job?.id} failed`, error);
        });
    }

    private async processNotification(job: Job<NotificationJob>) {
        const { email, trackingNumber, status, metadata } = job.data;

        try {
            const template = this.templateManager.getTemplate(status);
            const emailData: MailgunMessageData = {
                from: process.env.MAIL_FROM,
                to: email,
                subject: template.subject,
                html: this.templateManager.renderTemplate(template.html, {
                    trackingNumber,
                    ...metadata,
                }),
            };

            await this.mailgunClient.messages.create(
                process.env.MAILGUN_DOMAIN,
                emailData
            );
        } catch (error) {
            console.log(error);
            throw error;
        }
    }

    async queueNotification(data: NotificationJob) {
        await this.queue.add(JOB_NAMES.SEND_NOTIFICATION, data, {
            attempts: 5, // number of retry attempts before failing permanently
            backoff: {
                type: "exponential", // Retry using exponential backoff
                delay: 5000, // Delay in ms before retrying
            },
        });
        console.log("Job queued successfully");
    }
}
                            
                        

This code defines a NotificationService class that handles email notifications using a job queue system with BullMQ and Mailgun. This class initializes three private properties: queue, which is the queue where email jobs are added; mailgunClient, which is the Mailgun client for sending emails; and templateManager, which is an instance of the TemplateManager class that you created previously to manage and retrieve email templates. This class also defines the following methods:

  • setupWorker creates a worker that listens for new email jobs and processes them. It logs job completion or failure.
  • processNotification fetches the correct email template, populates it with dynamic data, and sends the email using Mailgun.
  • queueNotification adds a new email job to the queue, with retry attempts and exponential backoff for failed attempts. This method can be called from other parts of the application to add jobs to the queue.

Integrating with application events

To send email notifications when an order status is updated, you need to queue a job in the notification system. To do this, open the src/controllers/order.controller.ts file and add the following import statement to import the NotificationService class:

import { NotificationService } from "../services/notifications.service";

Next, define a private property inside the OrderController class to hold an instance of the NotificationService class:

private notificationService: NotificationService;

Initialize this property by adding the following code inside the constructor method:

this.notificationService = new NotificationService();

Lastly, add the following code inside the updateOrderStatus method after await this.orderRepository.save(order); to queue a job once the order status is successfully updated:

                            

                                // Send notification
this.notificationService.queueNotification({
    email: order.user.email,
    trackingNumber: order.trackingNumber,
    status,
    metadata: {
        name: order.user.name.split(" ")[0],
    },
});
                            
                        

The transactional email workflow is now complete. You can go ahead and test if everything is working as expected.

Testing the workflow

To ensure everything is working correctly, start Redis by running the following command in your terminal:

docker compose up -d

After running this command, you can verify that Redis is up and running by listing the containers:

docker ps

This command shows a list of active containers, and you should see the redis container running.

Next, run the Express server using the command npm run dev and navigate to http://localhost:3000 in your browser. On the application’s dashboard, update the order status to any of the available options. Once updated, you’ll receive an instant response:

Shipped Order Status Screen Image

In your terminal, you’ll get a log that notifies you that the email-sending job has been queued successfully and is being processed:

Job queued successfully
Processing job: 1

And when it’s done:

Job 1 completed successfully

You’ll then receive an email with the updated order status:

Your order has shipped screen image

Every time you update the status of an order, the system will automatically send a corresponding email to the user, keeping them informed:

Your Order has been delivered screen image 8

The full application code is available on GitHub.

Monitoring and managing emails

To ensure your transactional emails are reaching customers effectively, you need to monitor their delivery status and open rates and handle any failures. Mailgun provides a dashboard where you can track sent emails, check if they were delivered, opened, or clicked, and view bounce reports.

You can access this dashboard by navigating to Send > Reporting > Metrics in Mailgun:

Send > Reporting > Metrics Screen Image

However, open rates aren’t tracked automatically, and you need to configure them manually via your sending domain’s Settings tab:

Domain Settings Screen Tab image

More information on how to track open and click rates is available in the official documentation.

Additionally, you can use webhooks to receive real-time notifications about failed deliveries and take appropriate action, such as retrying the email.

By actively monitoring your email performance, you can improve deliverability, reduce bounces, and ensure important notifications reach your customers.

Wrapping up

In this article, you learned how to set up a transactional email workflow for shipping notifications using Mailgun, BullMQ, and Redis. You learned how to set up Mailgun for sending emails, configure a job queue with BullMQ, and integrate it into an order update system to ensure customers receive real-time email notifications. This approach improves communication and enhances the overall customer experience.

Was this helpful? Be sure to subscribe to our newsletter for more tutorial content and deep dives like this one.