Build a Reliable Email Scheduler

Build an email scheduler with delayed jobs, retries, and queue monitoring.

Introduction

30 Second Summary

Scheduling a message for later feels instant when you click a button. A dependable service still needs to remember that promise through restarts.

In this project, you will build a local email scheduling backend in TypeScript that stores future delivery requests in PostgreSQL. You will watch BullMQ process each delayed job through Ethereal after Redis holds its place in a durable queue.

What You'll Build

From Swagger UI, you watch a future email move from SCHEDULED to SENT before opening the captured message through its preview URL.

By the end of this project, you'll have:

  • A REST API that accepts a future email and immediately returns a durable ID for tracking its lifecycle.
  • A producer-worker pipeline that turns due jobs into Ethereal preview URLs while enforcing five-job concurrency, five jobs per second, retries, and duplicate guards.
  • An observability and recovery demo where Bull Board tracks a 100-email load test before the same delayed job survives API and worker restarts.
  • Secret Mission: Run a Redis outage drill that recovers the original delayed email from persistent volumes after a full local stack shutdown.

What do I need before starting?

You should be comfortable reading TypeScript and using a Linux terminal.

The first step covers Visual Studio Code, Docker Engine, and Docker Compose setup for Debian, Ubuntu, or Fedora.

Before We Start

This step locks in a producer-worker design before any hands-on work begins. The REST API persists each future email request for a separate worker to process later, with delivery status recorded as the job progresses.

Set Up the Local Backend Stack

A reliable scheduler needs the same supporting services every time it starts. Local installations can drift when the editor plus container tools come from different sources.

Your existing Node.js runtime handles the package environment. This step adds Visual Studio Code, Docker Engine, plus Docker Compose before creating the local workspace.

In this step, get ready to:
  • Install the editor plus the container tools for your Linux distribution.
  • Create the pinned Node.js project inside reliable-email-scheduler.
  • Start persistent PostgreSQL plus Redis services.
Install the local development tools

Your editor provides one workspace for every project file. The container tools run the database plus queue from reproducible images.

Choose the tab that matches your Linux distribution. Each path uses the vendor installation source for that distribution.

Ubuntu

  • Open the official Visual Studio Code Linux setup page.
  • Download the .deb package for Debian plus Ubuntu distributions.
  • Install the downloaded package through your graphical software center.
  • Configure Docker's Ubuntu repository by running these commands:
# Add Docker's official GPG key:
sudo apt update
sudo apt install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

# Add the repository to Apt sources:
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF

sudo apt update

What Do These Commands Do?

  • The first section adds Docker's signing key to your system.
  • The second section records Docker's Ubuntu package repository.
  • The final update makes packages from that repository available to the package manager.
  • Install Docker Engine plus the Docker Compose plugin by running:
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

What Does This Install?

The package set installs Docker Engine plus its command-line client. It also installs the Docker Compose plugin used throughout this project.

Debian

  • Open the official Visual Studio Code Linux setup page.
  • Download the .deb package for Debian plus Ubuntu distributions.
  • Install the downloaded package through your graphical software center.
  • Configure Docker's Debian repository by running these commands:
# Add Docker's official GPG key:
sudo apt update
sudo apt install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

# Add the repository to Apt sources:
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/debian
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF

sudo apt update

What Do These Commands Do?

  • The first section adds Docker's signing key to your system.
  • The second section records Docker's Debian package repository.
  • The final update makes packages from that repository available to the package manager.
  • Install Docker Engine plus the Docker Compose plugin by running:
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

What Does This Install?

The package set installs Docker Engine plus its command-line client. It also installs the Docker Compose plugin used throughout this project.

Fedora

  • Open the official Visual Studio Code Linux setup page.
  • Download the .rpm package for Fedora distributions.
  • Install the downloaded package through your graphical software center.
  • Configure Docker's Fedora repository by running this command:
sudo dnf config-manager addrepo --from-repofile https://download.docker.com/linux/fedora/docker-ce.repo

What Does This Command Do?

The command adds Docker's official Fedora repository. Your package manager can then install Docker Engine plus future updates from that source.

  • Install Docker Engine plus the Docker Compose plugin by running:
sudo dnf install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

What Does This Install?

The package set installs Docker Engine plus its command-line client. It also installs the Docker Compose plugin used throughout this project.

  • Start Docker Engine by running:
sudo systemctl enable --now docker

What Does This Command Do?

The command starts Docker Engine now. It also configures the service to start when Fedora boots.

Docker Access Has Elevated Privileges

The next permission grants your user root-level access through Docker. Use this setting only on a development machine you control.

  • Add your Linux user to the Docker group by running:
sudo usermod -aG docker $USER
newgrp docker

What Do These Commands Do?

  • The first command gives your current user permission to access Docker's local socket.
  • The second command activates the new group membership in the current terminal.

Before you run the checks, which tool do you expect to prove that Docker can start a real container?

  • Verify the editor plus both Docker commands by running:
code --version
docker --version
docker compose version
docker run hello-world

What Do These Checks Prove?

  • The first command confirms that the Visual Studio Code command-line launcher is installed.
  • The next two commands confirm that Docker Engine plus the Docker Compose plugin are available.
  • The final command downloads a test image. Docker starts a container from that image before exiting.

You should see a version from each installed tool. The final command should print a confirmation message from the test container.

Does a Tool Check Fail?

If the editor command is missing, close your terminal after installing Visual Studio Code. Start a new terminal so its command path refreshes.

If Docker reports a permission problem, confirm that both group commands completed. Signing out of Linux plus signing back in also refreshes group membership.

If Docker cannot connect to its service, confirm that Docker Engine is running through your distribution's service manager.

Help me diagnose my local tool setup.

Create the pinned project workspace

A pinned package.json makes every dependency version explicit. The supporting configuration keeps the same compiler settings plus service addresses on every run.

  • Create the reliable-email-scheduler workspace on your Desktop by running these commands:
cd ~/Desktop
mkdir reliable-email-scheduler
cd reliable-email-scheduler
mkdir -p src prisma scripts
touch package.json tsconfig.json .gitignore .env.example compose.yaml README.md
touch src/config.ts src/db.ts src/openapi.ts prisma/schema.prisma scripts/load.ts
code .

What Does This Setup Create?

The commands create the project folder plus the source directories needed by later steps. The final command opens that exact folder in Visual Studio Code.

The empty source files reserve the project paths without adding API or worker behavior in this setup step.

You should see reliable-email-scheduler at the top of the Explorer sidebar. Its src, prisma, plus scripts folders should be visible.

  • Select package.json in the Explorer sidebar.
  • Replace its empty contents with this package definition:
{
  "name": "reliable-email-scheduler",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": { "node": ">=20.12.0" },
  "scripts": {
    "dev:api": "tsx watch src/server.ts",
    "dev:worker": "tsx watch src/worker.ts",
    "typecheck": "tsc --noEmit",
    "load": "tsx scripts/load.ts",
    "prisma:generate": "prisma generate",
    "prisma:migrate": "prisma migrate dev"
  },
  "dependencies": {
    "@bull-board/api": "9.8.0",
    "@bull-board/express": "9.8.0",
    "@prisma/client": "6.19.0",
    "bullmq": "6.3.4",
    "express": "5.2.1",
    "nodemailer": "10.0.1",
    "swagger-ui-express": "5.0.1"
  },
  "devDependencies": {
    "@types/express": "5.0.6",
    "@types/node": "26.5.0",
    "@types/swagger-ui-express": "4.1.8",
    "prisma": "6.19.0",
    "tsx": "4.23.13",
    "typescript": "5.9.3"
  }
}

What Does This File Control?

  • The engine entry records the minimum Node.js runtime expected by the project.
  • The scripts provide stable commands for the API plus worker processes.
  • The dependency sections pin every runtime package plus development tool to an exact version.
  • Save package.json.
  • Install the pinned project dependencies from the VS Code terminal by running:
npm install

What Does This Command Do?

The command reads package.json before installing its dependencies into node_modules. It also records the resolved dependency tree in a lock file.

The first install can take a few minutes while npm downloads the package tree. Successful completion returns control to the terminal plus adds node_modules to the workspace.

Does npm Install Fail?

Check that the terminal path ends with reliable-email-scheduler. Running the command from another folder prevents npm from finding this package definition.

Check the braces plus commas in package.json if npm reports invalid JSON.

Help me diagnose my dependency installation.

  • Select tsconfig.json in the Explorer sidebar.
  • Add this TypeScript compiler configuration:
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "noEmit": true
  },
  "include": ["src/**/*.ts", "scripts/**/*.ts"]
}

What Does This Configuration Do?

  • The NodeNext settings align TypeScript modules with the project's Node.js module format.
  • Strict checking catches unsafe types before the application runs.
  • The no-emit setting turns the compiler command into a source check.
  • Save tsconfig.json.
  • Check the empty TypeScript workspace by running:
npm run typecheck

What Does This Check Prove?

The package script runs the local TypeScript compiler with the saved configuration. A successful check returns control to the terminal without reporting a type error.

  • Select .gitignore in the Explorer sidebar.
  • Add these local-only paths:
node_modules
.env
dist
*.log

Why Ignore These Paths?

Dependencies plus generated output can be recreated from project configuration. The environment file stays local because each machine can have different connection settings.

  • Save .gitignore.
  • Select .env.example in the Explorer sidebar.
  • Add the local service configuration:
DATABASE_URL="postgresql://scheduler:scheduler@localhost:5432/email_scheduler?schema=public"
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
PORT=3000

What Do These Values Connect?

The database URL targets the local PostgreSQL service with the scheduler account. The remaining values target Redis plus the future API port.

  • Save .env.example.
  • Create the active environment file by running:
cp .env.example .env

Why Copy the Environment File?

The example file documents the required settings. The copied .env file becomes the local configuration read by the application.

The Explorer sidebar should now show .env beside .env.example.

  • Select README.md in the Explorer sidebar.
  • Add the project reference guide:
# Reliable Email Scheduler

A local TypeScript backend that stores scheduled emails in PostgreSQL, queues delayed work in Redis with BullMQ, sends safe test messages through Ethereal, and exposes Swagger UI plus Bull Board.

## Requirements

- Node.js 20.12.0 or later
- npm
- Docker Engine with the Docker Compose plugin

## Start

1. Copy `.env.example` to `.env`.
2. Run `npm install`.
3. Run `docker compose up -d`.
4. Run `npx prisma migrate dev --name init`.
5. Run `npx prisma generate`.
6. Run `npm run dev:api` in one terminal.
7. Run `npm run dev:worker` in another terminal.

Open Swagger UI at http://localhost:3000/api-docs and Bull Board at http://localhost:3000/admin/queues.

## Load test

Run `LOAD_COUNT=100 npm run load` while the API and worker are active.

## Cleanup

Use `docker compose down` to preserve named volumes or `docker compose down -v` to delete them.

Ethereal captures messages for inspection and does not deliver them to real recipients.

What Does the README Capture?

The guide records the project requirements plus its future run commands. It also distinguishes cleanup that preserves data from cleanup that removes named volumes.

  • Save README.md.
  • Confirm the Explorer sidebar lists every project file created in this substep.
Start PostgreSQL and Redis

The API needs PostgreSQL for durable email records. Redis stores delayed queue jobs that must survive process restarts.

  • Select compose.yaml in the Explorer sidebar.
  • Add the two persistent service definitions:
services:
  postgres:
    image: postgres:16.15-alpine3.24
    environment:
      POSTGRES_USER: scheduler
      POSTGRES_PASSWORD: scheduler
      POSTGRES_DB: email_scheduler
    ports:
      - "5432:5432"
    volumes:
      - postgres-data:/var/lib/postgresql/data

  redis:
    image: redis:7.4.11-alpine3.21
    command: ["redis-server", "--appendonly", "yes"]
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data

volumes:
  postgres-data:
  redis-data:

What Does This Compose File Create?

  • The postgres service exposes the database on local port 5432.
  • The redis service exposes the queue store on local port 6379.
  • The postgres-data volume preserves database records across container replacement.
  • The redis-data volume preserves the Redis append-only data used by later recovery tests.
  • Save compose.yaml.

Before you start the stack, which two service names do you expect the status check to report?

  • Start the local infrastructure plus display its status by running:
docker compose up -d && docker compose ps

What Does This Command Do?

Docker Compose downloads both pinned images when they are missing. It starts the containers in the background before listing their current state.

The named volumes stay on disk when containers stop. That persistence becomes the evidence for the restart tests later in the project.

The first image download can take a few minutes. When startup completes, you should see the postgres plus redis services listed as running.

Are the Services Missing or Stopped?

Confirm that the terminal is inside reliable-email-scheduler. Docker Compose searches that location for compose.yaml.

Check the indentation beneath each service if Docker rejects the configuration. YAML uses spaces to express nesting.

Help me diagnose my local Compose stack.

Your environment now has a pinned package tree plus persistent local infrastructure. Double-check the setup files before moving into database behavior.

✔️ Awesome, I've got everything!

Great work. Your editor plus container stack are ready for the scheduling API.

ⓧ I'd like to double check the full code

DATABASE_URL="postgresql://scheduler:scheduler@localhost:5432/email_scheduler?schema=public"
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
PORT=3000

Environment File Check

This file points the application at the local PostgreSQL plus Redis ports.

node_modules
.env
dist
*.log

Ignored Files Check

This file excludes installed dependencies plus local configuration from version control.

services:
  postgres:
    image: postgres:16.15-alpine3.24
    environment:
      POSTGRES_USER: scheduler
      POSTGRES_PASSWORD: scheduler
      POSTGRES_DB: email_scheduler
    ports:
      - "5432:5432"
    volumes:
      - postgres-data:/var/lib/postgresql/data

  redis:
    image: redis:7.4.11-alpine3.21
    command: ["redis-server", "--appendonly", "yes"]
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data

volumes:
  postgres-data:
  redis-data:

Compose File Check

This file defines the two containers plus the two named volumes used throughout the project.

{
  "name": "reliable-email-scheduler",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": { "node": ">=20.12.0" },
  "scripts": {
    "dev:api": "tsx watch src/server.ts",
    "dev:worker": "tsx watch src/worker.ts",
    "typecheck": "tsc --noEmit",
    "load": "tsx scripts/load.ts",
    "prisma:generate": "prisma generate",
    "prisma:migrate": "prisma migrate dev"
  },
  "dependencies": {
    "@bull-board/api": "9.8.0",
    "@bull-board/express": "9.8.0",
    "@prisma/client": "6.19.0",
    "bullmq": "6.3.4",
    "express": "5.2.1",
    "nodemailer": "10.0.1",
    "swagger-ui-express": "5.0.1"
  },
  "devDependencies": {
    "@types/express": "5.0.6",
    "@types/node": "26.5.0",
    "@types/swagger-ui-express": "4.1.8",
    "prisma": "6.19.0",
    "tsx": "4.23.13",
    "typescript": "5.9.3"
  }
}

Package File Check

This file pins the project dependencies plus the commands used by later steps.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "noEmit": true
  },
  "include": ["src/**/*.ts", "scripts/**/*.ts"]
}

TypeScript File Check

This file applies strict Node.js module checking to the source plus load-test directories.

That is the local foundation in place. Next, you will persist a scheduled email through the first version of the API.

Persist Scheduled Emails Through an API

Your local PostgreSQL service is running. The scheduler still has nowhere to store an email request as durable application data.

An Express REST API will validate each request. Prisma will persist its lifecycle.

This first pass deliberately stops at persistence. You will watch a due record remain SCHEDULED because no execution process exists yet.

In this step, get ready to:
  • Generate the Prisma client from a migrated email schema.
  • Build an API that validates and persists future email requests.
  • Prove that persistence alone leaves a due email stuck in its scheduled state.
Define and migrate the email lifecycle

The EmailStatus enum gives every email a clear lifecycle state. The Email model stores the request plus the delivery details that later steps will add.

  • Return to the repository in Visual Studio Code.
  • Select prisma/schema.prisma in the Explorer sidebar.
  • Replace the file contents with this schema:
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

enum EmailStatus {
  SCHEDULED
  PROCESSING
  SENT
  FAILED
}

model Email {
  id          String      @id @default(uuid())
  jobId       String?     @unique
  to          String
  subject     String
  text        String
  scheduledAt DateTime
  status      EmailStatus @default(SCHEDULED)
  messageId   String?
  previewUrl  String?
  error       String?
  createdAt   DateTime    @default(now())
  updatedAt   DateTime    @updatedAt

  @@index([status, scheduledAt])
}

What Does This Schema Define?

  • The EmailStatus enum describes the four states an email can pass through.
  • The Email model stores the recipient plus the message content.
  • The scheduledAt field records when delivery should become eligible.
  • The index supports queries that combine lifecycle status with scheduled time.
  • Save prisma/schema.prisma.

✔️ Awesome, I've got everything!

Your saved schema now describes the complete email lifecycle needed by the API.

ⓧ I'd like to double check the full code

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

enum EmailStatus {
  SCHEDULED
  PROCESSING
  SENT
  FAILED
}

model Email {
  id          String      @id @default(uuid())
  jobId       String?     @unique
  to          String
  subject     String
  text        String
  scheduledAt DateTime
  status      EmailStatus @default(SCHEDULED)
  messageId   String?
  previewUrl  String?
  error       String?
  createdAt   DateTime    @default(now())
  updatedAt   DateTime    @updatedAt

  @@index([status, scheduledAt])
}

How to Compare the Schema

Compare each enum value plus each model field with your saved file. Pay close attention to the optional question marks and the final index.

  • Return to the terminal panel in the repository.
  • Apply the initial migration plus generate the Prisma client by running these commands:
npx prisma migrate dev --name init
npx prisma generate

What Do These Commands Create?

The migration command applies the schema to PostgreSQL. It also records the database change in a migration.

The generation command creates the typed client used by src/db.ts. Your API can now query the Email model.

The terminal completes both commands without a migration failure. The generated Prisma client now matches your schema.

Does the Migration Fail?

Confirm that the PostgreSQL container from the previous step is still running. Check that DATABASE_URL in .env uses port 5432.

Check the reported schema line for a missing field type or closing brace.

Help me diagnose the Prisma migration.

Build the persistence API

The API needs a runnable foundation before it handles email requests. A health route provides the first visible proof that Express can reach PostgreSQL.

  • Create src/server.ts inside the existing src folder with the VS Code Explorer sidebar.
  • Paste this runnable API foundation into src/server.ts:
import express, { type NextFunction, type Request, type Response } from "express";
import swaggerUi from "swagger-ui-express";
import { config } from "./config.js";
import { prisma } from "./db.js";
import { openapiDocument } from "./openapi.js";

const app = express();
const emailStatuses = ["SCHEDULED", "PROCESSING", "SENT", "FAILED"] as const;
type EmailStatusValue = (typeof emailStatuses)[number];

app.use(express.json());
app.get("/health", async (_req, res) => { await prisma.email.count(); res.json({ status: "ok" }); });
const server = app.listen(config.port);
async function shutdown() { server.close(); await prisma.$disconnect(); process.exit(0); }
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);

What Does This Foundation Do?

  • The imports connect Express to the existing environment configuration and Prisma client.
  • The JSON middleware makes request bodies available to later routes.
  • The /health route counts email records to prove that the database connection works.
  • The shutdown handlers close the server plus the database connection when the process stops.
  • Save src/server.ts.
  • Start the API from the repository terminal by running this command:
npm run dev:api

What Does This Command Start?

The package script starts src/server.ts in watch mode. Keep this terminal process running while you edit the API.

  • Open a browser tab at http://localhost:3000/health.
  • Confirm that the response shows {"status":"ok"}.

That first response is live proof that your API can query the migrated database.

Does the Health Check Fail?

Check the API terminal for a TypeScript or database connection problem. Confirm that port 3000 is available.

Confirm that the PostgreSQL container remains running before refreshing the health route.

Help me fix the API health check.

The health route proves that the basic process works. Request parsing now gives the email routes one trusted input shape.

  • In src/server.ts locate the EmailStatusValue type.
  • Insert these helpers directly below that type:
class HttpError extends Error {
  constructor(public readonly status: number, message: string) {
    super(message);
  }
}

function isEmailStatus(value: string): value is EmailStatusValue {
  return emailStatuses.some((status) => status === value);
}

function parseScheduleRequest(body: unknown) {
  if (!body || typeof body !== "object") throw new HttpError(400, "Request body must be a JSON object");
  const candidate = body as Record<string, unknown>;
  const to = typeof candidate.to === "string" ? candidate.to.trim() : "";
  const subject = typeof candidate.subject === "string" ? candidate.subject.trim() : "";
  const text = typeof candidate.text === "string" ? candidate.text.trim() : "";
  const scheduledAt = typeof candidate.scheduledAt === "string" ? new Date(candidate.scheduledAt) : new Date(Number.NaN);
  if (!to.includes("@")) throw new HttpError(400, "to must contain an email address");
  if (!subject || !text) throw new HttpError(400, "subject and text are required");
  if (Number.isNaN(scheduledAt.getTime()) || scheduledAt <= new Date()) throw new HttpError(400, "scheduledAt must be a valid future date-time");
  return { to, subject, text, scheduledAt };
}

How Does Validation Work?

  • The HttpError class carries the HTTP status that belongs with a validation problem.
  • The isEmailStatus() helper rejects unsupported status filters.
  • The parseScheduleRequest() helper trims the request fields.
  • The same helper rejects invalid recipients plus timestamps that are not in the future.
  • Save src/server.ts.
  • Check the running API terminal for a clean watch-mode restart.

The API restarts without a TypeScript error. Your validation helpers now compile inside the running process.

Does the API Stop After This Edit?

Compare the braces around HttpError plus parseScheduleRequest(). Confirm that each helper sits above app.use(express.json()).

Help me find the TypeScript error.

The remaining routes turn validated input into durable email records. The shared error handler returns each failure as a JSON response.

  • In src/server.ts find the existing /health route.
  • Insert these routes plus middleware directly below the health route:
app.post("/emails", async (req, res) => { const email = await prisma.email.create({ data: parseScheduleRequest(req.body) }); res.status(202).json(email); });
app.get("/emails", async (req, res) => {
  const rawStatus = typeof req.query.status === "string" ? req.query.status : undefined;
  if (rawStatus && !isEmailStatus(rawStatus)) throw new HttpError(400, `status must be one of ${emailStatuses.join(", ")}`);
  const items = await prisma.email.findMany({ where: rawStatus ? { status: rawStatus } : undefined, orderBy: { createdAt: "desc" }, take: 50 });
  res.json({ items });
});
app.get("/emails/:id", async (req, res) => { const email = await prisma.email.findUnique({ where: { id: req.params.id } }); if (!email) throw new HttpError(404, "Email not found"); res.json(email); });
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(openapiDocument));
app.use((error: Error, _req: Request, res: Response, _next: NextFunction) => { const status = error instanceof HttpError ? error.status : 500; res.status(status).json({ error: error.message }); });

What Do the Routes Provide?

  • The POST /emails route validates a request before creating a database record.
  • The GET /emails route returns the latest records with an optional lifecycle filter.
  • The GET /emails/:id route retrieves one record by its database ID.
  • The final two middleware registrations expose Swagger UI plus JSON error responses.
  • Save src/server.ts.
  • Open http://localhost:3000/api-docs in your browser.
  • Confirm that the page lists POST /emails plus both email query operations.

Your API documentation now exposes the persistence operations through one browser page.

Are the Email Routes Missing?

Confirm that the Swagger middleware sits below the email routes. Check that src/openapi.ts still exports openapiDocument.

Refresh the documentation page after the watch process restarts.

Help me restore the API documentation.

✔️ Awesome, I've got everything!

Your running server now validates email requests plus persists them through Prisma.

ⓧ I'd like to double check the full code

import express, { type NextFunction, type Request, type Response } from "express";
import swaggerUi from "swagger-ui-express";
import { config } from "./config.js";
import { prisma } from "./db.js";
import { openapiDocument } from "./openapi.js";

const app = express();
const emailStatuses = ["SCHEDULED", "PROCESSING", "SENT", "FAILED"] as const;
type EmailStatusValue = (typeof emailStatuses)[number];

class HttpError extends Error {
  constructor(public readonly status: number, message: string) {
    super(message);
  }
}

function isEmailStatus(value: string): value is EmailStatusValue {
  return emailStatuses.some((status) => status === value);
}

function parseScheduleRequest(body: unknown) {
  if (!body || typeof body !== "object") throw new HttpError(400, "Request body must be a JSON object");
  const candidate = body as Record<string, unknown>;
  const to = typeof candidate.to === "string" ? candidate.to.trim() : "";
  const subject = typeof candidate.subject === "string" ? candidate.subject.trim() : "";
  const text = typeof candidate.text === "string" ? candidate.text.trim() : "";
  const scheduledAt = typeof candidate.scheduledAt === "string" ? new Date(candidate.scheduledAt) : new Date(Number.NaN);
  if (!to.includes("@")) throw new HttpError(400, "to must contain an email address");
  if (!subject || !text) throw new HttpError(400, "subject and text are required");
  if (Number.isNaN(scheduledAt.getTime()) || scheduledAt <= new Date()) throw new HttpError(400, "scheduledAt must be a valid future date-time");
  return { to, subject, text, scheduledAt };
}

app.use(express.json());
app.get("/health", async (_req, res) => { await prisma.email.count(); res.json({ status: "ok" }); });
app.post("/emails", async (req, res) => { const email = await prisma.email.create({ data: parseScheduleRequest(req.body) }); res.status(202).json(email); });
app.get("/emails", async (req, res) => {
  const rawStatus = typeof req.query.status === "string" ? req.query.status : undefined;
  if (rawStatus && !isEmailStatus(rawStatus)) throw new HttpError(400, `status must be one of ${emailStatuses.join(", ")}`);
  const items = await prisma.email.findMany({ where: rawStatus ? { status: rawStatus } : undefined, orderBy: { createdAt: "desc" }, take: 50 });
  res.json({ items });
});
app.get("/emails/:id", async (req, res) => { const email = await prisma.email.findUnique({ where: { id: req.params.id } }); if (!email) throw new HttpError(404, "Email not found"); res.json(email); });
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(openapiDocument));
app.use((error: Error, _req: Request, res: Response, _next: NextFunction) => { const status = error instanceof HttpError ? error.status : 500; res.status(status).json({ error: error.message }); });
const server = app.listen(config.port);
async function shutdown() { server.close(); await prisma.$disconnect(); process.exit(0); }
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);

How to Compare the Server

Confirm that the helpers appear before the middleware. Confirm that the error handler appears after the documented routes.

Expose and test the API in Swagger UI

A successful scheduling response proves that PostgreSQL can remember future work. Waiting past the due time reveals whether anything can execute that work.

  • Return to the Swagger UI page from earlier.
  • Select the POST /emails operation.
  • Enable its editable request form.
  • Enter learner@example.com for to.
  • Enter Persistence check for subject.
  • Enter This should stay scheduled for text.
  • Enter a valid ISO date-time at least 20 seconds in the future for scheduledAt.

Before you submit the request, do you think this email will leave SCHEDULED after its due time passes?

  • Submit the scheduling request.
  • Confirm that the response uses status 202.
  • Confirm that the response record has status SCHEDULED.
  • Record the response ID here: your scheduled email ID.
  • Wait until at least 20 seconds have passed.

Before you retrieve the record, what state do you expect now that its scheduled time has passed?

  • Select the GET /emails/{id} operation.
  • Enter your scheduled email ID in the route parameter.
  • Submit the retrieval request.

The response still shows SCHEDULED. That shortfall is intentional.

PostgreSQL preserved the request after its due time. This API has no process that executes due work.

You have proved that the API can remember future work without completing it. Next, you will connect BullMQ so a separate worker can wake up when delivery becomes due.

Deliver Delayed Jobs with BullMQ and Ethereal

Your PostgreSQL database already remembers every scheduled email. The missing execution process leaves each due record stuck at SCHEDULED.

BullMQ stores delayed jobs in Redis. A separate worker delivers each due message through Ethereal Email.

In this step, get ready to:
  • Define the Redis-backed email queue.
  • Turn each database ID into a delayed BullMQ job.
  • Run a separate worker that delivers captured test messages.
Connect the API to a delayed queue

A BullMQ queue gives the API somewhere durable to place future work. The database ID becomes the job payload plus the job ID.

Why BullMQ Instead of Database Polling?

BullMQ supplies delayed execution through Redis. It also provides the foundation for the retries plus throughput controls you add next.

A database polling loop needs custom scheduling logic. It also needs custom locking plus retry behavior.

  • Select the src folder in the VS Code Explorer sidebar.
  • Click the new-file control at the top of the Explorer sidebar.
  • Enter queue.ts as the file name.
  • Define the queue connection by copying this code into src/queue.ts:
import { Queue } from "bullmq";
import { config } from "./config.js";

export const EMAIL_QUEUE = "scheduled-emails";

export interface EmailJob {
  emailId: string;
}

export const redisConnection = {
  host: config.redisHost,
  port: config.redisPort
};

export const emailQueue = new Queue<EmailJob>(EMAIL_QUEUE, {
  connection: redisConnection
});

What Does This Queue Do?

  • The EMAIL_QUEUE constant gives the API plus the worker the same queue name.
  • The EmailJob interface defines the database ID carried by each job.
  • The redisConnection object uses the host plus port from config.
  • The emailQueue instance lets the API add delayed work to Redis.
  • Save src/queue.ts.
  • Confirm queue.ts now appears inside the src folder in the Explorer sidebar.

Seeing Queue Type Errors?

Check that Queue is imported from bullmq. Confirm that both local import paths end in .js.

Compare the Redis property names with redisHost plus redisPort in src/config.ts.

Help me fix the queue file.

✔️ Awesome, I've got everything!

Your saved src/queue.ts file now exports the shared queue name plus the Redis connection.

ⓧ I'd like to double check the full code

import { Queue } from "bullmq";
import { config } from "./config.js";

export const EMAIL_QUEUE = "scheduled-emails";

export interface EmailJob {
  emailId: string;
}

export const redisConnection = {
  host: config.redisHost,
  port: config.redisPort
};

export const emailQueue = new Queue<EmailJob>(EMAIL_QUEUE, {
  connection: redisConnection
});

How to Compare This File

Confirm the queue name is scheduled-emails. Confirm the exported job payload contains emailId.

  • Select src/server.ts in the Explorer sidebar.
  • Find the import for prisma near the top of the file.
  • Add the queue import directly below it so this section looks like:
import { prisma } from "./db.js";
import { emailQueue } from "./queue.js";
import { openapiDocument } from "./openapi.js";

Why Import the Queue Here?

The API acts as the job producer. The imported emailQueue instance lets the scheduling route hand work to Redis.

  • Find the existing route that begins with app.post("/emails".
  • Replace that entire one-line route with this queue-producing version:
app.post("/emails", async (req, res) => {
  const input = parseScheduleRequest(req.body);
  const email = await prisma.email.create({ data: input });
  try {
    const job = await emailQueue.add("send-email", { emailId: email.id }, {
      jobId: email.id,
      delay: Math.max(0, email.scheduledAt.getTime() - Date.now())
    });
    const saved = await prisma.email.update({
      where: { id: email.id },
      data: { jobId: job.id }
    });
    res.status(202).json(saved);
  } catch (error) {
    await prisma.email.update({
      where: { id: email.id },
      data: {
        status: "FAILED",
        error: error instanceof Error ? error.message : String(error)
      }
    });
    throw error;
  }
});

How Does Scheduling Work Now?

  • The API creates the database record before adding queue work.
  • The email.id value becomes the BullMQ jobId.
  • The delay value measures the remaining milliseconds before scheduledAt.
  • The Math.max() call prevents a negative delay.
  • A queue failure changes the database record to FAILED.
  • Save src/server.ts.
  • Start the updated API in the existing terminal by running:
npm run dev:api

What Does This Command Start?

The dev:api script runs src/server.ts in watch mode. The terminal remains occupied while the API accepts requests.

  • Keep the API terminal session running.
  • Enter http://localhost:3000/health in your browser address bar.
  • Load the health endpoint.

You should see {"status":"ok"}. The API still reaches PostgreSQL after the queue integration.

Does the API Stop During Startup?

Confirm that the PostgreSQL plus Redis containers from the previous step are still running. Check that src/queue.ts uses the exact exports referenced by src/server.ts.

Help me diagnose the API startup.

Build the Ethereal worker

The worker is an independent process that waits for due BullMQ jobs. Nodemailer creates one reusable transporter for the temporary Ethereal account.

Ethereal captures each test message for inspection. It does not deliver the message to a real recipient.

  • Select the src folder in the Explorer sidebar.
  • Click the new-file control at the top of the Explorer sidebar.
  • Enter worker.ts as the file name.
  • Set up the Ethereal account plus transporter by copying this first section into src/worker.ts:
import { Worker } from "bullmq";
import nodemailer from "nodemailer";
import { prisma } from "./db.js";
import { EMAIL_QUEUE, type EmailJob, redisConnection } from "./queue.js";

const testAccount = await nodemailer.createTestAccount();
const transporter = nodemailer.createTransport({
  host: testAccount.smtp.host,
  port: testAccount.smtp.port,
  secure: testAccount.smtp.secure,
  auth: { user: testAccount.user, pass: testAccount.pass }
});

What Does the Transporter Hold?

  • The testAccount value contains the temporary SMTP details from Ethereal.
  • The transporter value reuses those details for every message handled by this process.
  • The queue imports give the worker the same queue name plus Redis connection as the API.
  • Save src/worker.ts.
  • Confirm VS Code shows no unresolved import errors in the saved section.

Seeing Import Errors?

Check that every local import ends in .js. Confirm that EMAIL_QUEUE plus redisConnection are exported from src/queue.ts.

Help me fix the worker imports.

  • Place the cursor below the transporter definition.
  • Add the worker processor plus graceful shutdown code by copying this section:
const worker = new Worker<EmailJob>(EMAIL_QUEUE, async (job) => {
  const email = await prisma.email.findUnique({ where: { id: job.data.emailId } });
  if (!email) throw new Error(`Email ${job.data.emailId} does not exist`);
  await prisma.email.update({ where: { id: email.id }, data: { status: "PROCESSING", error: null } });
  try {
    const info = await transporter.sendMail({ from: `"Reliable Scheduler" <${testAccount.user}>`, to: email.to, subject: email.subject, text: email.text });
    const testMessageUrl = nodemailer.getTestMessageUrl(info);
    const previewUrl = typeof testMessageUrl === "string" ? testMessageUrl : null;
    await prisma.email.update({ where: { id: email.id }, data: { status: "SENT", messageId: info.messageId, previewUrl, error: null } });
    return { messageId: info.messageId, previewUrl };
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    await prisma.email.update({ where: { id: email.id }, data: { status: "FAILED", error: message } });
    throw error;
  }
}, { connection: redisConnection });

async function shutdown() { await worker.close(); await prisma.$disconnect(); process.exit(0); }
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);

How Does the Worker Deliver a Job?

  • The worker loads the email using job.data.emailId.
  • The database status changes to PROCESSING before the SMTP request.
  • A successful send stores messageId plus previewUrl.
  • A failed send stores the error message plus the FAILED status.
  • The shutdown handler closes the worker plus the database connection.
  • Save src/worker.ts.
  • Confirm the file ends with handlers for SIGINT plus SIGTERM.

✔️ Awesome, I've got everything!

Your worker now connects BullMQ jobs to the matching PostgreSQL records plus Ethereal messages.

ⓧ I'd like to double check the full code

import { Worker } from "bullmq";
import nodemailer from "nodemailer";
import { prisma } from "./db.js";
import { EMAIL_QUEUE, type EmailJob, redisConnection } from "./queue.js";

const testAccount = await nodemailer.createTestAccount();
const transporter = nodemailer.createTransport({
  host: testAccount.smtp.host,
  port: testAccount.smtp.port,
  secure: testAccount.smtp.secure,
  auth: { user: testAccount.user, pass: testAccount.pass }
});

const worker = new Worker<EmailJob>(EMAIL_QUEUE, async (job) => {
  const email = await prisma.email.findUnique({ where: { id: job.data.emailId } });
  if (!email) throw new Error(`Email ${job.data.emailId} does not exist`);
  await prisma.email.update({ where: { id: email.id }, data: { status: "PROCESSING", error: null } });
  try {
    const info = await transporter.sendMail({ from: `"Reliable Scheduler" <${testAccount.user}>`, to: email.to, subject: email.subject, text: email.text });
    const testMessageUrl = nodemailer.getTestMessageUrl(info);
    const previewUrl = typeof testMessageUrl === "string" ? testMessageUrl : null;
    await prisma.email.update({ where: { id: email.id }, data: { status: "SENT", messageId: info.messageId, previewUrl, error: null } });
    return { messageId: info.messageId, previewUrl };
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    await prisma.email.update({ where: { id: email.id }, data: { status: "FAILED", error: message } });
    throw error;
  }
}, { connection: redisConnection });

async function shutdown() { await worker.close(); await prisma.$disconnect(); process.exit(0); }
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);

How to Compare This File

Confirm the transporter appears before the Worker constructor. Confirm the shutdown handlers appear at the bottom.

  • Create another terminal session in the existing VS Code terminal panel.
  • Start the worker in that new terminal by running:
npm run dev:worker

What Does This Command Start?

The dev:worker script runs src/worker.ts in watch mode. The active process waits for jobs from scheduled-emails.

The worker terminal should remain active. Keep both terminal sessions running for the delivery test.

Does the Worker Exit?

Confirm the Redis container is running. Check that the queue name in src/worker.ts comes from EMAIL_QUEUE.

A network problem can also prevent creation of the temporary Ethereal account. Retry after confirming your internet connection.

Help me diagnose the worker process.

Run the delayed delivery

The API plus worker now form a producer-worker pipeline. One process accepts the request while the other waits for its due time.

  • Enter http://localhost:3000/api-docs in your browser address bar.
  • Load Swagger UI.
  • Expand the POST /emails endpoint.
  • Enable its request editor.
  • Enter recipient-1@example.com for to.
  • Enter Delayed delivery for subject.
  • Enter BullMQ delivered this message. for text.
  • Set scheduledAt to a valid date-time 15 seconds in the future.

Before you submit the request, predict which status the API returns before the worker reaches the due time.

  • Submit the scheduling request from the endpoint panel.

The response has status SCHEDULED. Its jobId matches its database id.

  • Record the returned ID here: your email ID.
  • Expand the GET /emails/{id} endpoint.
  • Enter your email ID in the ID field.
  • Submit the lookup immediately.

You should still see SCHEDULED before the due time. Redis is holding the delayed job.

The short pause is expected while BullMQ waits for the scheduled timestamp. Give the worker at least 15 seconds to claim the job.

  • Wait at least 15 seconds.

Before you check again, predict whether the same record still says SCHEDULED or has reached SENT.

  • Submit the same GET /emails/{id} lookup again.

The original record now shows SENT. Its previewUrl value is no longer null.

  • Record the returned preview address here: your Ethereal preview URL.
  • Open your Ethereal preview URL in a new browser tab.
  • Confirm the captured recipient is recipient-1@example.com.
  • Confirm the captured subject is Delayed delivery.
  • Confirm the captured text is BullMQ delivered this message..

That is the hard part working. One database ID now travels from acceptance to delayed delivery.

Does the Record Stay Scheduled?

Confirm the worker terminal is still active. Check that both processes use the same scheduled-emails queue.

Confirm the scheduled timestamp has passed. Check the worker terminal for a database or SMTP failure.

Help me trace the stuck job.

Your scheduler now delivers future work through a separate process. Next, you will bound its concurrency plus throughput while adding duplicate safeguards.

Add Throughput and Duplicate Guards

Delayed delivery now works through your BullMQ worker. A burst of due jobs could still overwhelm the shared Ethereal sender.

This step adds bounded concurrency. It also adds retries plus idempotency guards that reduce repeated delivery.

In this step, get ready to:
  • Limit the worker to five parallel jobs plus five starts per second.
  • Add retries plus duplicate-delivery safeguards.
  • Run a ten-email load test against the delivery pipeline.
Bound worker throughput

A worker's concurrency setting caps the jobs it processes in parallel. A rate limiter controls how many jobs can start during a time window.

  • Select src/worker.ts in the VS Code Explorer sidebar.
  • Find }, { connection: redisConnection }); at the end of the Worker constructor.
  • Replace the existing worker options with this configuration:
}, {
  connection: redisConnection,
  concurrency: 5,
  limiter: { max: 5, duration: 1000 }
});

How Is Throughput Bounded?

  • The concurrency value permits up to five jobs to run in parallel.
  • The limiter permits five jobs to start within each 1000 millisecond window.
  • The global limit protects the single Ethereal sender account used by this project.
  • Save src/worker.ts.
  • Return to the worker terminal from earlier.
  • Confirm the watcher restarts without a TypeScript error.

Worker Does Not Restart?

Confirm that the new options remain inside the third argument of the Worker constructor. Check that the constructor ends with });.

Check the first reported line number in src/worker.ts. A misplaced brace near the limiter can prevent the watcher from restarting.

Help me diagnose the worker throughput configuration.

Structured JSON logs expose the worker's active limits. They also make connection-level failures easier to trace.

  • Find the closing }); for the worker constructor.
  • Add these log records directly below the constructor:
worker.on("error", (error) => {
  console.error(JSON.stringify({ event: "worker.error", error: error.message }));
});

console.log(JSON.stringify({
  event: "worker.ready",
  queue: EMAIL_QUEUE,
  concurrency: 5,
  maxPerSecond: 5,
  etherealUser: testAccount.user
}));

What Do These Logs Reveal?

  • The worker.error event records worker-level failures as structured output.
  • The worker.ready record exposes the queue name plus the configured throughput limits.
  • The Ethereal username identifies the test sender without exposing its password.
  • Save src/worker.ts.
  • Return to the worker terminal from earlier.
  • Find the new worker.ready record.

You should see concurrency set to 5. You should also see maxPerSecond set to 5.

Add retry and duplicate safeguards

Retries give temporary delivery failures another chance. Stable job IDs plus a PostgreSQL sent-state check reduce duplicate work.

  • Select src/queue.ts in the VS Code Explorer sidebar.
  • Find the emailQueue constructor at the bottom of the file.
  • Replace the constructor with this retry configuration:
export const emailQueue = new Queue<EmailJob>(EMAIL_QUEUE, {
  connection: redisConnection,
  defaultJobOptions: {
    attempts: 3,
    backoff: { type: "exponential", delay: 1000 },
    removeOnComplete: false,
    removeOnFail: false
  }
});

How Do Retries Work?

  • The attempts value gives each job up to three delivery attempts.
  • The exponential backoff begins with a 1000 millisecond delay.
  • The removal settings retain completed jobs plus failed jobs for inspection.
  • Save src/queue.ts.
  • Return to the worker terminal from earlier.
  • Confirm the watcher prints a fresh worker.ready record.

Queue Configuration Does Not Compile?

Confirm that defaultJobOptions sits beside connection inside the queue options object.

Check the commas after connection plus backoff. Missing punctuation can stop the worker watcher.

Help me diagnose the BullMQ retry configuration.

✔️ Awesome, I've got everything!

Your queue now retries temporary failures. It also retains completed plus failed job records.

ⓧ I'd like to double check the full code

import { Queue } from "bullmq";
import { config } from "./config.js";

export const EMAIL_QUEUE = "scheduled-emails";

export interface EmailJob {
  emailId: string;
}

export const redisConnection = {
  host: config.redisHost,
  port: config.redisPort
};

export const emailQueue = new Queue<EmailJob>(EMAIL_QUEUE, {
  connection: redisConnection,
  defaultJobOptions: {
    attempts: 3,
    backoff: { type: "exponential", delay: 1000 },
    removeOnComplete: false,
    removeOnFail: false
  }
});

The API already uses each database email ID as the BullMQ jobId. Reusing that ID prevents another queue job from being added with the same identity.

  • Return to src/worker.ts.
  • Find the missing-email check inside the worker processor.
  • Add this sent-state guard directly below the check:
  if (email.status === "SENT") {
    return { skipped: true, messageId: email.messageId, previewUrl: email.previewUrl };
  }

Why Check the Database First?

A retried job can reach the worker after its email is already recorded as sent. The guard returns the stored result before another SMTP request occurs.

This is a practical at-least-once safeguard. A crash after SMTP acceptance can still leave a duplicate window if PostgreSQL has not recorded the result.

  • Save src/worker.ts.
  • Return to the worker terminal from earlier.
  • Confirm the watcher prints another worker.ready record.

Successful delivery logs connect each queue job to its database record. The same record also carries the captured Ethereal message details.

  • Find the successful database update inside the worker's try block.
  • Add this structured log directly below that update:
    console.log(JSON.stringify({
      event: "email.sent",
      emailId: email.id,
      jobId: job.id,
      messageId: info.messageId,
      previewUrl
    }));

What Does the Delivery Log Capture?

  • The emailId value identifies the durable PostgreSQL record.
  • The jobId value identifies the corresponding BullMQ job.
  • The message details make each successful Ethereal capture traceable.
  • Save src/worker.ts.
  • Return to the worker terminal from earlier.
  • Confirm the watcher prints a fresh worker.ready record.

✔️ Awesome, I've got everything!

Your worker now bounds throughput. It also skips database records already marked as sent.

ⓧ I'd like to double check the full code

import { Worker } from "bullmq";
import nodemailer from "nodemailer";
import { prisma } from "./db.js";
import { EMAIL_QUEUE, type EmailJob, redisConnection } from "./queue.js";

const testAccount = await nodemailer.createTestAccount();
const transporter = nodemailer.createTransport({
  host: testAccount.smtp.host,
  port: testAccount.smtp.port,
  secure: testAccount.smtp.secure,
  auth: { user: testAccount.user, pass: testAccount.pass }
});

const worker = new Worker<EmailJob>(EMAIL_QUEUE, async (job) => {
  const email = await prisma.email.findUnique({ where: { id: job.data.emailId } });
  if (!email) throw new Error(`Email ${job.data.emailId} does not exist`);
  if (email.status === "SENT") {
    return { skipped: true, messageId: email.messageId, previewUrl: email.previewUrl };
  }

  await prisma.email.update({
    where: { id: email.id },
    data: { status: "PROCESSING", error: null }
  });

  try {
    const info = await transporter.sendMail({
      from: `"Reliable Scheduler" <${testAccount.user}>`,
      to: email.to,
      subject: email.subject,
      text: email.text
    });
    const testMessageUrl = nodemailer.getTestMessageUrl(info);
    const previewUrl = typeof testMessageUrl === "string" ? testMessageUrl : null;
    await prisma.email.update({
      where: { id: email.id },
      data: { status: "SENT", messageId: info.messageId, previewUrl, error: null }
    });
    console.log(JSON.stringify({
      event: "email.sent",
      emailId: email.id,
      jobId: job.id,
      messageId: info.messageId,
      previewUrl
    }));
    return { messageId: info.messageId, previewUrl };
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    await prisma.email.update({
      where: { id: email.id },
      data: { status: "FAILED", error: message }
    });
    throw error;
  }
}, {
  connection: redisConnection,
  concurrency: 5,
  limiter: { max: 5, duration: 1000 }
});

worker.on("error", (error) => {
  console.error(JSON.stringify({ event: "worker.error", error: error.message }));
});

console.log(JSON.stringify({
  event: "worker.ready",
  queue: EMAIL_QUEUE,
  concurrency: 5,
  maxPerSecond: 5,
  etherealUser: testAccount.user
}));

async function shutdown() {
  await worker.close();
  await prisma.$disconnect();
  process.exit(0);
}

process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);

Worker Reports a Code Problem?

Confirm that the sent-state guard appears before the update to PROCESSING. The guard must run before the SMTP request.

Confirm that the email.sent log remains inside the try block. It depends on the successful delivery variables defined there.

Help me check the duplicate guard plus worker logs.

Load test ten scheduled emails

A TypeScript load generator creates repeatable pressure on the REST API. It schedules requests in batches of ten.

  • Click the New Folder control in the VS Code Explorer sidebar.
  • Name the folder scripts.

You should see the new scripts folder inside the local repository.

  • Select the scripts folder in the Explorer sidebar.
  • Click the New File control.
  • Name the file load.ts.

You should see scripts/load.ts in the Explorer sidebar.

  • Add the request batching logic to scripts/load.ts by copying this code:
const apiUrl = process.env.API_URL ?? "http://localhost:3000";
const requestedCount = Number(process.env.LOAD_COUNT ?? 100);
const count = Number.isFinite(requestedCount)
  ? Math.min(100, Math.max(1, Math.trunc(requestedCount)))
  : 100;
const scheduledAt = new Date(Date.now() + 5000).toISOString();
const ids: string[] = [];

for (let start = 0; start < count; start += 10) {
  const batchSize = Math.min(10, count - start);
  const batch = await Promise.all(Array.from({ length: batchSize }, async (_, offset) => {
    const index = start + offset + 1;
    const response = await fetch(`${apiUrl}/emails`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ to: `recipient-${index}@example.com`, subject: `Load test ${index}`, text: `Scheduled message ${index}`, scheduledAt })
    });
    if (!response.ok) throw new Error(`Schedule request ${index} failed: ${response.status}`);
    return (await response.json()) as { id: string };
  }));
  ids.push(...batch.map((email) => email.id));
}

console.log(`scheduled=${ids.length}`);

How Does the Generator Schedule Work?

  • The requested count is constrained to a range from one through 100.
  • Every generated email receives the same timestamp five seconds in the future.
  • Each batch submits up to ten requests in parallel.
  • The returned database IDs identify the emails created by the current run.
  • Save scripts/load.ts.
  • Confirm the API watcher from earlier is active.
  • Confirm the worker watcher from earlier is active.
  • Schedule ten emails with the partial generator by running this command:
LOAD_COUNT=10 npm run load

What Does This First Run Prove?

The command starts the project's load script with a count of ten. This first version submits the requests plus prints the number of accepted IDs.

You should see scheduled=10 before the script exits. That output proves the API accepted the complete batch.

The Batch Was Not Accepted?

Confirm that the API watcher is still active on port 3000. The load script sends every scheduling request to that local API.

Check the load terminal for the failed request number plus its response status. That result identifies the first rejected submission.

Help me diagnose the scheduling batch.

The generator now needs a completion loop. Polling the API turns the accepted batch into a measurable delivery test.

  • Add this polling loop below the scheduled log in scripts/load.ts.
const deadline = Date.now() + 60000;
while (Date.now() < deadline) {
  const response = await fetch(`${apiUrl}/emails?limit=100`);
  const payload = (await response.json()) as { items: Array<{ id: string; status: string }> };
  const selected = payload.items.filter((email) => ids.includes(email.id));
  const sent = selected.filter((email) => email.status === "SENT").length;
  const failed = selected.filter((email) => email.status === "FAILED").length;
  console.log(`sent=${sent} failed=${failed} remaining=${count - sent - failed}`);
  if (sent + failed === count) process.exit(failed === 0 ? 0 : 1);
  await new Promise((resolve) => setTimeout(resolve, 1000));
}
throw new Error("Load test timed out after 60 seconds");

How Does the Completion Loop Work?

  • The deadline gives the batch up to 60 seconds to finish.
  • Each poll selects records whose IDs belong to the current run.
  • The counters expose sent jobs plus failed jobs plus unfinished jobs.
  • The process exits successfully when every selected record reaches a final state without failures.
  • Save scripts/load.ts.

✔️ Awesome, I've got everything!

Your load generator can now schedule a batch. It tracks every returned ID to a final state.

ⓧ I'd like to double check the full code

const apiUrl = process.env.API_URL ?? "http://localhost:3000";
const requestedCount = Number(process.env.LOAD_COUNT ?? 100);
const count = Number.isFinite(requestedCount)
  ? Math.min(100, Math.max(1, Math.trunc(requestedCount)))
  : 100;
const scheduledAt = new Date(Date.now() + 5000).toISOString();
const ids: string[] = [];

for (let start = 0; start < count; start += 10) {
  const batchSize = Math.min(10, count - start);
  const batch = await Promise.all(Array.from({ length: batchSize }, async (_, offset) => {
    const index = start + offset + 1;
    const response = await fetch(`${apiUrl}/emails`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ to: `recipient-${index}@example.com`, subject: `Load test ${index}`, text: `Scheduled message ${index}`, scheduledAt })
    });
    if (!response.ok) throw new Error(`Schedule request ${index} failed: ${response.status}`);
    return (await response.json()) as { id: string };
  }));
  ids.push(...batch.map((email) => email.id));
}

console.log(`scheduled=${ids.length}`);
const deadline = Date.now() + 60000;
while (Date.now() < deadline) {
  const response = await fetch(`${apiUrl}/emails?limit=100`);
  const payload = (await response.json()) as { items: Array<{ id: string; status: string }> };
  const selected = payload.items.filter((email) => ids.includes(email.id));
  const sent = selected.filter((email) => email.status === "SENT").length;
  const failed = selected.filter((email) => email.status === "FAILED").length;
  console.log(`sent=${sent} failed=${failed} remaining=${count - sent - failed}`);
  if (sent + failed === count) process.exit(failed === 0 ? 0 : 1);
  await new Promise((resolve) => setTimeout(resolve, 1000));
}
throw new Error("Load test timed out after 60 seconds");

Before you run the complete test, how do you expect the remaining count to change under the new limits?

  • Run the complete ten-email load test with this command:
LOAD_COUNT=10 npm run load

What Does the Final Run Prove?

The script prints updated delivery counts while the worker processes the batch. The final update reports sent=10 failed=0 with remaining=0.

The worker.ready record confirms a concurrency ceiling of five. Its throughput field confirms a maximum of five starts per second.

  • Switch back to the worker terminal from earlier.
  • Find the worker.ready record for the active process.
  • Confirm its concurrency value is 5.
  • Confirm its maxPerSecond value is 5.
  • Compare the ten database IDs with the email.sent records.

That is your reliability layer working under pressure. Ten scheduled emails reached SENT within the configured parallelism plus sender throughput bounds.

Load Test Does Not Reach Ten?

Confirm that both watchers are active. The load script needs the API for polling plus the worker for delivery.

Check the worker terminal for a worker.error record. Check the load terminal for a nonzero failed count.

Help me diagnose the bounded load test.

Your scheduler now controls delivery pressure plus repeated work. Next, you will inspect the queue under a 100-email load test and prove that a future job survives process restarts.

Observe, Load Test, and Restart the Scheduler

Your scheduler already controls parallel work. It also guards the single sender account from excess throughput.

Reliability claims need evidence under volume plus process interruption. You will use Bull Board to observe the existing BullMQ queue. You will also prove that a delayed job survives an application restart.

In this step, get ready to:
  • Add queue observability to the existing API.
  • Process 100 scheduled emails while monitoring job states.
  • Prove that a delayed email survives API and worker restarts.
Mount Bull Board on the existing queue

Bull Board reads queue state through an adapter. Reusing emailQueue keeps the dashboard connected to the same delayed jobs that your API creates.

  • Select src/server.ts in the VS Code Explorer sidebar.
  • Find the swagger-ui-express import near the top of the file.
  • Add the Bull Board imports directly below it:
import { createBullBoard } from "@bull-board/api";
import { BullMQAdapter } from "@bull-board/api/bullMQAdapter";
import { ExpressAdapter } from "@bull-board/express";

What Do These Imports Provide?

  • The createBullBoard function connects queue adapters to a dashboard.
  • The BullMQAdapter class wraps the existing BullMQ queue.
  • The ExpressAdapter class exposes the dashboard through Express.
  • Find the existing app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(openapiDocument)); line.
  • Add this dashboard setup directly below that line:
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath("/admin/queues");
createBullBoard({ queues: [new BullMQAdapter(emailQueue)], serverAdapter });
app.use("/admin/queues", serverAdapter.getRouter());

How Does the Dashboard Reuse the Queue?

  • The serverAdapter serves Bull Board at /admin/queues.
  • The BullMQAdapter receives the existing emailQueue instance.
  • The API mounts the adapter router without creating a second queue.
  • Save src/server.ts.
  • Confirm the API terminal returns to its ready state after the automatic restart.
  • Enter http://localhost:3000/admin/queues in your browser address bar.
  • Load the queue dashboard.

You will see the existing scheduled-emails queue. Its retained jobs provide the history for completed or failed work.

Does the Queue Dashboard Fail to Load?

Check the API terminal for a TypeScript error near the new imports. Confirm that the dashboard block appears after the Swagger UI route.

Confirm that emailQueue still comes from ./queue.js.

Help me debug the Bull Board route.

A useful health check verifies every dependency needed to accept a scheduled email. This route checks PostgreSQL through Prisma. It also checks Redis through the queue.

  • Find the existing app.get("/health" route in src/server.ts.
  • Replace that route with this block:
app.get("/health", async (_req, res) => {
  await Promise.all([prisma.email.count(), emailQueue.getJobCounts()]);
  res.json({ status: "ok" });
});

What Does the Health Check Prove?

  • The prisma.email.count() call verifies that the API can query the email table.
  • The emailQueue.getJobCounts() call verifies that the API can read queue state.
  • The route returns success after both checks resolve.
  • Save src/server.ts.
  • Enter http://localhost:3000/health in your browser address bar.
  • Load the health route.

You will see {"status":"ok"}. That response proves the API can reach both durable stores.

Does the Health Check Fail?

Confirm that the PostgreSQL plus Redis containers from earlier are still running. Check the API terminal to identify which connection failed.

Verify that the health route passes both promises to Promise.all().

Help me diagnose the health route.

✔️ Awesome, I've got everything!

Great. Your API now exposes the existing queue through Bull Board. Its health route also checks both durable stores.

ⓧ I'd like to double check the full code

import express, { type NextFunction, type Request, type Response } from "express";
import swaggerUi from "swagger-ui-express";
import { createBullBoard } from "@bull-board/api";
import { BullMQAdapter } from "@bull-board/api/bullMQAdapter";
import { ExpressAdapter } from "@bull-board/express";
import { config } from "./config.js";
import { prisma } from "./db.js";
import { emailQueue } from "./queue.js";
import { openapiDocument } from "./openapi.js";

const app = express();
const emailStatuses = ["SCHEDULED", "PROCESSING", "SENT", "FAILED"] as const;
type EmailStatusValue = (typeof emailStatuses)[number];

class HttpError extends Error {
  constructor(public readonly status: number, message: string) {
    super(message);
  }
}

function isEmailStatus(value: string): value is EmailStatusValue {
  return emailStatuses.some((status) => status === value);
}

function parseScheduleRequest(body: unknown) {
  if (!body || typeof body !== "object") {
    throw new HttpError(400, "Request body must be a JSON object");
  }
  const candidate = body as Record<string, unknown>;
  const to = typeof candidate.to === "string" ? candidate.to.trim() : "";
  const subject = typeof candidate.subject === "string" ? candidate.subject.trim() : "";
  const text = typeof candidate.text === "string" ? candidate.text.trim() : "";
  const scheduledAt = typeof candidate.scheduledAt === "string"
    ? new Date(candidate.scheduledAt)
    : new Date(Number.NaN);

  if (!to.includes("@")) throw new HttpError(400, "to must contain an email address");
  if (!subject || !text) throw new HttpError(400, "subject and text are required");
  if (Number.isNaN(scheduledAt.getTime()) || scheduledAt <= new Date()) {
    throw new HttpError(400, "scheduledAt must be a valid future date-time");
  }
  return { to, subject, text, scheduledAt };
}

app.use(express.json());

app.get("/health", async (_req, res) => {
  await Promise.all([prisma.email.count(), emailQueue.getJobCounts()]);
  res.json({ status: "ok" });
});

app.post("/emails", async (req, res) => {
  const input = parseScheduleRequest(req.body);
  const email = await prisma.email.create({ data: input });
  try {
    const job = await emailQueue.add("send-email", { emailId: email.id }, {
      jobId: email.id,
      delay: Math.max(0, email.scheduledAt.getTime() - Date.now())
    });
    const saved = await prisma.email.update({
      where: { id: email.id },
      data: { jobId: job.id }
    });
    res.status(202).json(saved);
  } catch (error) {
    await prisma.email.update({
      where: { id: email.id },
      data: {
        status: "FAILED",
        error: error instanceof Error ? error.message : String(error)
      }
    });
    throw error;
  }
});

app.get("/emails", async (req, res) => {
  const rawStatus = typeof req.query.status === "string" ? req.query.status : undefined;
  if (rawStatus && !isEmailStatus(rawStatus)) {
    throw new HttpError(400, `status must be one of ${emailStatuses.join(", ")}`);
  }
  const requestedLimit = Number(req.query.limit ?? 50);
  const limit = Number.isFinite(requestedLimit)
    ? Math.min(100, Math.max(1, Math.trunc(requestedLimit)))
    : 50;
  const items = await prisma.email.findMany({
    where: rawStatus ? { status: rawStatus } : undefined,
    orderBy: { createdAt: "desc" },
    take: limit
  });
  res.json({ items });
});

app.get("/emails/:id", async (req, res) => {
  const email = await prisma.email.findUnique({ where: { id: req.params.id } });
  if (!email) throw new HttpError(404, "Email not found");
  res.json(email);
});

app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(openapiDocument));

const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath("/admin/queues");
createBullBoard({ queues: [new BullMQAdapter(emailQueue)], serverAdapter });
app.use("/admin/queues", serverAdapter.getRouter());

app.use((error: Error, _req: Request, res: Response, _next: NextFunction) => {
  const status = error instanceof HttpError ? error.status : 500;
  res.status(status).json({ error: error.message });
});

const server = app.listen(config.port, () => {
  console.log(`API: http://localhost:${config.port}`);
  console.log(`Swagger UI: http://localhost:${config.port}/api-docs`);
  console.log(`Bull Board: http://localhost:${config.port}/admin/queues`);
});

async function shutdown() {
  server.close();
  await emailQueue.close();
  await prisma.$disconnect();
  process.exit(0);
}

process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);

Compare this reference with src/server.ts. It includes the health check plus the Bull Board adapter for the existing queue.

import { Worker } from "bullmq";
import nodemailer from "nodemailer";
import { prisma } from "./db.js";
import { EMAIL_QUEUE, type EmailJob, redisConnection } from "./queue.js";

const testAccount = await nodemailer.createTestAccount();
const transporter = nodemailer.createTransport({
  host: testAccount.smtp.host,
  port: testAccount.smtp.port,
  secure: testAccount.smtp.secure,
  auth: { user: testAccount.user, pass: testAccount.pass }
});

const worker = new Worker<EmailJob>(EMAIL_QUEUE, async (job) => {
  const email = await prisma.email.findUnique({ where: { id: job.data.emailId } });
  if (!email) throw new Error(`Email ${job.data.emailId} does not exist`);
  if (email.status === "SENT") {
    return { skipped: true, messageId: email.messageId, previewUrl: email.previewUrl };
  }

  await prisma.email.update({
    where: { id: email.id },
    data: { status: "PROCESSING", error: null }
  });

  try {
    const info = await transporter.sendMail({
      from: `"Reliable Scheduler" <${testAccount.user}>`,
      to: email.to,
      subject: email.subject,
      text: email.text
    });
    const testMessageUrl = nodemailer.getTestMessageUrl(info);
    const previewUrl = typeof testMessageUrl === "string" ? testMessageUrl : null;
    await prisma.email.update({
      where: { id: email.id },
      data: { status: "SENT", messageId: info.messageId, previewUrl, error: null }
    });
    console.log(JSON.stringify({
      event: "email.sent",
      emailId: email.id,
      jobId: job.id,
      messageId: info.messageId,
      previewUrl
    }));
    return { messageId: info.messageId, previewUrl };
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    await prisma.email.update({
      where: { id: email.id },
      data: { status: "FAILED", error: message }
    });
    throw error;
  }
}, {
  connection: redisConnection,
  concurrency: 5,
  limiter: { max: 5, duration: 1000 }
});

worker.on("error", (error) => {
  console.error(JSON.stringify({ event: "worker.error", error: error.message }));
});

console.log(JSON.stringify({
  event: "worker.ready",
  queue: EMAIL_QUEUE,
  concurrency: 5,
  maxPerSecond: 5,
  etherealUser: testAccount.user
}));

async function shutdown() {
  await worker.close();
  await prisma.$disconnect();
  process.exit(0);
}

process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);

Compare this reference with src/worker.ts. The worker keeps its existing concurrency plus limiter settings for the tests ahead.

Measure the queue under load

The load generator schedules requests in batches of ten. It polls the API until every selected database record reaches a final state.

This run can take up to 60 seconds. The changing remaining count shows that the test is still progressing.

Before you run the load test, do you expect all 100 jobs to finish without bypassing the worker's throughput controls?

  • Return to the terminal you used for the earlier load test.
  • Launch 100 scheduled emails by running this command:
LOAD_COUNT=100 npm run load

What Does This Run Measure?

The command asks scripts/load.ts to create 100 records. The script reports each polling result while the worker drains the queue.

A successful run ends with sent=100 failed=0. The final total connects queue processing with persisted API state.

  • Switch back to the Bull Board page from earlier.
  • Refresh the scheduled-emails queue view.
  • Inspect the retained completed jobs.
  • Check the dashboard for delayed jobs.
  • Check the dashboard for active jobs.
  • Check the dashboard for failed jobs.

You will see completed job records from the run. The terminal reports sent=100 failed=0 when every matching API record is sent.

Does the Load Test Stall or Fail?

Confirm that the API terminal still shows a running process. Confirm that the worker terminal still shows its ready record.

Use Bull Board to identify whether jobs are delayed or failed. Read the worker's structured log for the affected email ID.

Help me diagnose the load test.

Test restart recovery

A durable scheduler keeps future work outside the memory of its API process. The same delayed operation should remain available while only the application processes restart.

Choosing the future timestamp is a little fiddly. Leave at least 30 seconds for the shutdown plus restart sequence.

  • Switch back to Swagger UI at http://localhost:3000/api-docs.
  • Expand the POST /emails operation.
  • Enable editing for the request body.
  • Enter an email address in to.
  • Enter Restart recovery in subject.
  • Enter This message survived a restart. in text.
  • Set scheduledAt to a valid date-time at least 30 seconds in the future.
  • Submit the scheduling request.

The response shows a SCHEDULED record. Its database ID also acts as the stable queue job ID.

  • Copy the returned ID here: your scheduled email ID.

Why Save the Original ID?

The ID connects the database record to its delayed queue job. Checking the same value after the restart proves that the scheduler recovered existing work.

  • Switch to the API terminal.
  • Press Ctrl+C to stop the API process.
  • Switch to the worker terminal.
  • Press Ctrl+C to stop the worker process.

The PostgreSQL plus Redis containers remain running. Their named volumes continue holding the email record plus delayed job.

  • Return to the API terminal.
  • Restart the API process by running:
npm run dev:api

What Does the API Restart Restore?

The command starts the API from src/server.ts. The process reconnects to the existing database plus queue state.

  • Return to the worker terminal.
  • Restart the worker process by running:
npm run dev:worker

What Does the Worker Restart Restore?

The command creates a new test transport. The worker reconnects to the existing scheduled-emails queue.

The terminal prints a worker.ready record. That record confirms the independent execution process is listening again.

  • Wait until the scheduled time has passed.

Before you check the record, do you expect the original ID to stay scheduled or reach its sent state?

  • Switch back to Swagger UI.
  • Expand the GET /emails/{id} operation.
  • Enter your scheduled email ID as the path value.
  • Submit the lookup request.

The response keeps the original ID. It now shows SENT with a non-null previewUrl.

  • Return to Bull Board.
  • Inspect the job whose ID matches your scheduled email ID.
  • Confirm that the existing job reached its completed state.

Does the Recovered Email Stay Scheduled?

Confirm that the worker restarted before the scheduled time. Check its terminal for a ready record or a structured worker error.

Confirm that the API lookup uses the original ID. Use Bull Board to locate the matching delayed or failed job.

Help me diagnose restart recovery.

That is the reliability proof complete. Your scheduler now exposes its queue under load. It also recovers future work after both application processes restart.

Secret mission

Run a Redis Outage Drill

Take your delayed scheduler through a full local infrastructure outage. Recover the original database record plus BullMQ job from named volumes without scheduling a replacement.

Clean Up Your Resources

Clean Up Your Resources

Everything in this project runs locally, so there are no ongoing financial costs. Choose whether to keep the stack active, pause it, or delete its local resources.

The named volumes continue using local disk space until you delete them.

Resources you used:

  • The running Node.js API process plus its worker process.
  • Two Docker containers running PostgreSQL plus Redis.
  • The postgres-data named volume containing the email database.
  • The redis-data named volume containing Redis AOF plus the recovered BullMQ queue named scheduled-emails.
  • Temporary Ethereal Email credentials stored only in worker memory.
  • The project folder currently open in Visual Studio Code.

Keep everything running

No action is needed. Choose this option if you want to continue testing scheduled emails or inspect the recovered queue.

  • Leave the API process running.
  • Leave the worker process running.
  • Keep the postgres container active.
  • Keep the redis container active.
  • Retain both named volumes for future recovery tests.
  • Keep the current project folder open in Visual Studio Code.

Pause - I'll come back to this later

Shut down the running processes to free their ports. Your project folder plus both named volumes remain ready for another session.

  • Switch back to the API terminal from earlier.
  • Press Ctrl+C in the API terminal.
  • Switch back to the worker terminal from earlier.
  • Press Ctrl+C in the worker terminal.
  • Return to the project terminal from earlier.
  • Stop the containers while preserving both named volumes by running this command:
docker compose down

What Does This Command Do?

This command stops the project containers. It preserves postgres-data plus redis-data for your next session.

  • Confirm that the containers stopped by running this command:
docker compose ps

What Does This Check Show?

You should see no running postgres or redis services for this project. This confirms that the stack is paused.

Containers Still Running?

  • Return to the project terminal from earlier before running the shutdown command again.
  • Use the same Docker access method that worked during setup.
  • Help me diagnose why my Docker Compose services remain running.

Your API plus worker are now stopped. Their ports are free while the persisted scheduler data remains available.

Delete - I don't want to use this again

Deleting these resources is permanent. Your successful recovery result still captures what you proved before the local data disappears.

  • Switch back to the API terminal from earlier.
  • Press Ctrl+C in the API terminal.
  • Switch back to the worker terminal from earlier.
  • Press Ctrl+C in the worker terminal.

Stopping the worker discards its in-memory Ethereal credentials.

  • Return to the project terminal from earlier.
  • Remove both containers plus both named volumes by running this command:
docker compose down -v

What Does This Command Do?

This command stops and removes the two project containers. The -v flag also removes postgres-data plus redis-data.

You should see output confirming removal of the containers. You should also see removal confirmations for both named volumes.

Named Volumes Still Present?

  • Check that the command includes the -v flag.
  • Run the command from the project terminal from earlier.
  • Help me verify that Docker removed this project's named volumes.
  • Use the file sidebar in Visual Studio Code to reveal the current project folder in your Linux file manager.
  • Close Visual Studio Code.
  • Delete the revealed project folder from your Linux file manager.
  • Confirm that the project folder no longer appears in its former location.

The backend source files plus installed local dependencies are now removed. Your machine no longer holds the scheduler's project resources.

Nice Work!

Nice Work!

You did it! You built a local email scheduling backend that carries a future request from durable storage to a message captured by Ethereal Email.

What you learned:

  • Build a TypeScript REST API powered by Express. Validate future email requests through Swagger UI. Persist each lifecycle transition in PostgreSQL through Prisma ORM.
  • Process durable delayed jobs in a separate BullMQ worker backed by Redis. Deliver safe test messages through Nodemailer. Bound delivery to five concurrent jobs. Hold throughput to five jobs per second. Reduce duplicate risk with stable job IDs. Skip records already marked SENT.
  • Observe every queue state in Bull Board. Correlate completed jobs with a 100-email load test. Prove that a future job survives application process restarts.
  • Secret Mission: Recover the original delayed job after a full local stack shutdown. Restore its state from postgres-data plus redis-data. Verify that the same Email ID reaches SENT without a replacement job.

Ready to quiz yourself?