Skip to content

DavidEricson00/LetterDrop

Repository files navigation

LetterDrop

A production-grade newsletter platform built with NestJS microservices and RabbitMQ, demonstrating asynchronous event-driven architecture, idempotent delivery, and resilient message processing.

Architecture Overview

┌─────────────┐     REST      ┌─────────────────────────────────────────┐
│   Client    │ ────────────► │                  API                    │
└─────────────┘               │  NestJS · PostgreSQL · JWT · Swagger    │
                              └────────────────┬────────────────────────┘
                                               │
                                    RabbitMQ Events
                                               │
                              ┌────────────────▼────────────────────────┐
                              │                Mailer                   │
                              │  NestJS Microservice · Resend · Pino    │
                              └─────────────────────────────────────────┘

Services

Service Description Port
API Main REST API subscribers, letters, auth, admin panel 3000
Mailer RabbitMQ consumer email dispatch, delivery tracking 3001
PostgreSQL Relational database (shared, API-only access) 5432
RabbitMQ Message broker with management UI 5672 / 15672

Messaging Topology

letters.exchange  ──► letters_queue ──► [LettersConsumer]
                                 │ (on failure)
                                 └──► letters.dlx ──► letters_dead_queue ──► [DeadLettersConsumer]

subscribers.exchange ──► confirmations_queue ──► [ConfirmationsConsumer]
                                       │ (on failure)
                                       └──► letters.dlx ──► letters_dead_queue

Key Features

Asynchronous Email Dispatch Admin triggers a letter send; the API publishes a letter.send event and responds immediately. The Mailer processes subscribers in batches of 5, with configurable concurrency and rate limiting.

Idempotent Delivery Each subscriber delivery is tracked in a letter_deliveries table with a unique constraint. If the Mailer crashes mid-send and restarts, it resumes from the last checkpoint without re-sending emails.

Dead Letter Queue Failed messages are routed to letters_dead_queue via a dead letter exchange. The DeadLettersConsumer logs the x-death header for audit and observability.

Bounce Handling When Resend returns a validation_error (HTTP 422) for a subscriber's email, the Mailer immediately marks that subscriber as BOUNCED via the internal API. Bounced subscribers are excluded from future sends. The status is reversible if another user registers the same email, they go through normal confirmation flow.

Partial Send Status If some deliveries fail and others succeed, the letter is marked PARTIAL with counters for total_sent and total_failed, rather than a misleading SENT.

Letter Lifecycle

DRAFT → QUEUED → PROCESSING → SENT
                            ↘ PARTIAL
                            ↘ FAILED

Subscriber Lifecycle

PENDING → CONFIRMED → UNSUBSCRIBED
                    ↘ BOUNCED → CONFIRMED (re-registration)

Token-based Confirmation Confirmation and unsubscribe tokens are hashed with SHA-256 before storage. Plain tokens are emitted via RabbitMQ to the Mailer, which sends them by email the API never stores them in plain text.

Security

  • JWT authentication with role-based access control (ADMIN, SUPER_ADMIN)
  • Internal service-to-service communication secured with a shared x-internal-key header
  • Global rate limiting via @nestjs/throttler
  • Artificial delay on failed login attempts to mitigate brute force

Tech Stack

Layer Technology
Runtime Node.js
Framework NestJS
Language TypeScript
Database PostgreSQL + TypeORM
Messaging RabbitMQ + @nestjs/microservices
Email Provider Resend
Auth JWT + Passport
Logging Pino + nestjs-pino
Validation class-validator + Zod (env schema)
Configuration @nestjs/config + Zod schema validation
API Docs Swagger / OpenAPI
Health Checks @nestjs/terminus
Testing Jest (unit + e2e with SQLite in-memory)
Containers Docker + Docker Compose

Getting Started

Prerequisites

1. Clone the repository

git clone https://github.com/DavidEricson00/LetterDrop.git
cd LetterDrop

2. Configure environment variables

All environment variables are validated at startup using Zod. The application will fail fast with a descriptive error if any required variable is missing or malformed.

Infrastructure (RabbitMQ + PostgreSQL):

cp .env.docker.example .env.docker

Edit .env.docker:

POSTGRES_USER=letterdrop
POSTGRES_PASSWORD=your_password
POSTGRES_DB=letterdrop

RABBITMQ_USER=admin
RABBITMQ_PASS=your_password

API:

cp api/.env.example api/.env

Edit api/.env:

PORT=3000
NODE_ENV=production

DB_HOST=postgres
DB_PORT=5432
DB_USERNAME=letterdrop
DB_PASSWORD=your_password
DB_NAME=letterdrop
DB_SYNCHRONIZE=true

RABBITMQ_URL=amqp://admin:your_password@rabbitmq:5672

JWT_SECRET=your_jwt_secret
JWT_EXPIRES_IN=1d

INTERNAL_API_KEY=your_internal_key

SUPER_ADMIN_EMAIL=admin@example.com
SUPER_ADMIN_PASSWORD=your_super_admin_password

Mailer:

cp mailer/.env.example mailer/.env

Edit mailer/.env:

PORT=3001
NODE_ENV=production

RABBITMQ_URL=amqp://admin:your_password@rabbitmq:5672

API_URL=http://api:3000
INTERNAL_API_KEY=your_internal_key

RESEND_API_KEY=re_your_resend_key
FROM_EMAIL=newsletter@yourdomain.com

Note: Without a verified domain on Resend, FROM_EMAIL must be onboarding@resend.dev and emails can only be sent to your own Resend account email.

3. Start all services

docker compose up -d

All services start with dependency health checks. The API waits for PostgreSQL and RabbitMQ to be healthy before starting. The Mailer waits for RabbitMQ.

4. Verify everything is running

URL Description
http://localhost:3000/docs Swagger UI (API docs)
http://localhost:3000/health API health check
http://localhost:3001/health Mailer health check
http://localhost:15672 RabbitMQ management UI

The super admin account is created automatically on first start using the credentials from SUPER_ADMIN_EMAIL and SUPER_ADMIN_PASSWORD.

Development (without Docker)

Start only the infrastructure:

docker compose up postgres rabbitmq -d

Then run each service locally:

# API
cd api
npm install
npm run start:dev

# Mailer
cd mailer
npm install
npm run start:dev

Update .env files to use localhost instead of service names:

DB_HOST=localhost
RABBITMQ_URL=amqp://admin:your_password@localhost:5672
API_URL=http://localhost:3000

Seed subscribers (development)

cd api
npm run seed

Running Tests

# API unit tests
cd api && npm run test

# API e2e tests (requires .env.test)
cd api && npm run test:e2e

# Mailer unit tests
cd mailer && npm run test

E2e tests use SQLite in-memory database and mock RabbitMQ connections no external services required.

API Documentation

Full interactive documentation is available at http://localhost:3000/docs when the API is running.

Public Endpoints

Method Path Description
POST /subscribers Subscribe with an email
GET /subscribers/confirm/:token Confirm subscription
GET /subscribers/unsubscribe/:token Unsubscribe
POST /subscribers/resend-confirmation Resend the confirmation email
POST /subscribers/resend-unsubscribe Resend the unsubscribe email
POST /auth/login Admin login

Protected Endpoints (JWT required)

Method Path Description
GET /subscribers List all subscribers
POST /letters Create a letter (draft)
GET /letters List all letters
GET /letters/:id Get a single letter
PATCH /letters/:id Update a draft letter
POST /letters/:id/send Queue letter for sending
DELETE /letters/:id Delete a draft or failed letter

Admin Management (SUPER_ADMIN only)

Method Path Description
POST /admins Create an admin
GET /admins List all admins
GET /admins/:id Get a single admin
PATCH /admins/:id Update an admin
PATCH /admins/:id/activate Activate an admin
PATCH /admins/:id/deactivate Deactivate an admin
PATCH /admins/:id/password Reset admin password

Project Structure

letterdrop/
├── api/                        # Main REST API
│   └── src/
│       ├── admins/             # Admin management + SUPER_ADMIN bootstrap
│       ├── auth/               # JWT auth, guards, strategies
│       ├── letters/            # Letter CRUD, state machine, internal endpoints
│       ├── letter-deliveries/  # Idempotent delivery tracking
│       ├── subscribers/        # Subscriber lifecycle, token flows
│       ├── rabbit/             # RabbitMQ client module
│       ├── health/             # Health check endpoint
│       └── common/             # Exception filters
│
├── mailer/                     # Email dispatch microservice
│   └── src/
│       ├── mail/               # MailService, BatchSenderService, providers
│       ├── messaging/          # RabbitMQ consumers (letters, confirmations, DLQ)
│       ├── common/             # InternalApiClient, retry/timeout utils
│       └── health/             # Health check endpoint
│
├── contracts/                  # Shared TypeScript interfaces (events, HTTP responses)
│
├── rabbitmq/
│   ├── entrypoint.sh           # Generates definitions.json from env vars at startup
│   └── rabbitmq.conf
│
└── docker-compose.yml

Design Decisions

Why two NestJS services instead of one? Decoupling the email dispatch from the API allows the API to respond immediately to the admin without waiting for potentially thousands of emails to be sent. If the Mailer goes down, the queue holds the messages nothing is lost.

Why a contracts package? Shared TypeScript interfaces between API and Mailer ensure both services agree on event payloads and HTTP response shapes at compile time, preventing silent mismatches.

Why checkpoint-based resumption? If the Mailer crashes while processing a letter with 10,000 subscribers and 6,000 have been sent, it would be costly and wrong to restart from zero. The checkpoint stores the last processed offset, so the Mailer resumes exactly where it left off.

Why hash tokens before storing them? If the database is compromised, hashed tokens cannot be used directly to confirm subscriptions or trigger unsubscribes. The plain token is only ever transmitted over the wire via email.

Why PARTIAL status instead of SENT when some emails fail? Marking a letter as SENT when 10% of subscribers didn't receive it is misleading. PARTIAL with total_sent and total_failed counters gives the admin accurate visibility into what actually happened.

Why Zod for environment validation? NestJS's built-in config validation with joi is common, but Zod provides stronger TypeScript inference the validated env object is fully typed throughout the application, catching configuration errors at compile time rather than at runtime.

License

MIT

About

A production-grade newsletter platform built with NestJS microservices and RabbitMQ

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors