A production-grade newsletter platform built with NestJS microservices and RabbitMQ, demonstrating asynchronous event-driven architecture, idempotent delivery, and resilient message processing.
┌─────────────┐ REST ┌─────────────────────────────────────────┐
│ Client │ ────────────► │ API │
└─────────────┘ │ NestJS · PostgreSQL · JWT · Swagger │
└────────────────┬────────────────────────┘
│
RabbitMQ Events
│
┌────────────────▼────────────────────────┐
│ Mailer │
│ NestJS Microservice · Resend · Pino │
└─────────────────────────────────────────┘
| 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 |
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
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-keyheader - Global rate limiting via
@nestjs/throttler - Artificial delay on failed login attempts to mitigate brute force
| 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 |
git clone https://github.com/DavidEricson00/LetterDrop.git
cd LetterDropAll 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.dockerEdit .env.docker:
POSTGRES_USER=letterdrop
POSTGRES_PASSWORD=your_password
POSTGRES_DB=letterdrop
RABBITMQ_USER=admin
RABBITMQ_PASS=your_passwordAPI:
cp api/.env.example api/.envEdit 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_passwordMailer:
cp mailer/.env.example mailer/.envEdit 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.comNote: Without a verified domain on Resend,
FROM_EMAILmust beonboarding@resend.devand emails can only be sent to your own Resend account email.
docker compose up -dAll services start with dependency health checks. The API waits for PostgreSQL and RabbitMQ to be healthy before starting. The Mailer waits for RabbitMQ.
| 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.
Start only the infrastructure:
docker compose up postgres rabbitmq -dThen run each service locally:
# API
cd api
npm install
npm run start:dev
# Mailer
cd mailer
npm install
npm run start:devUpdate .env files to use localhost instead of service names:
DB_HOST=localhost
RABBITMQ_URL=amqp://admin:your_password@localhost:5672
API_URL=http://localhost:3000cd api
npm run seed# 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 testE2e tests use SQLite in-memory database and mock RabbitMQ connections no external services required.
Full interactive documentation is available at http://localhost:3000/docs when the API is running.
| 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 |
| 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 |
| 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 |
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
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.
MIT