Email Campaign & Delivery Tracking Platform
A production-style email marketing and delivery-analytics platform — campaign authoring, recipient list management, queued sending, webhook-driven delivery tracking (sent, delivered, opened, clicked, bounced, complained, unsubscribed) and campaign analytics — built with NestJS, MongoDB, React/TypeScript and AWS.
1. Executive Summary
The platform lets an organization create an email campaign, send it to a recipient list, and track exactly what happened to every message: queued → sent → delivered → opened → clicked, with side branches for bounced, failed, marked as spam, and unsubscribed. It demonstrates backend engineering depth that a CRUD app cannot: asynchronous queue processing, inbound webhook ingestion with signature verification, idempotent event handling, and campaign-level analytics rollups — the exact skill set a Node.js/AWS backend role screens for.
Scope is deliberately kept to what a demo needs to prove the architecture: one email-sending provider integration (Amazon SES, with a provider-agnostic webhook layer that would also accept SendGrid/Mailgun/Postmark payloads), one organization per signed-in workspace, and synthetic seed data only (fictional company "NovaMail Retail Co.", fake contacts, no real email addresses are ever sent to in the demo — see §29 Docker section for the local mail-catcher setup).
2. Why This Is a Top-Tier Portfolio Project
Order-management and CRM demos are common; a correctly-modeled event-sourced delivery pipeline is not. This project is chosen specifically because it forces decisions a reviewer will recognize as production-grade:
Backend depth it proves
- Queue-based, at-least-once background processing (SQS + worker)
- Inbound webhook ingestion with HMAC signature verification
- Idempotent event handling (duplicate webhook delivery is a fact of life for every ESP)
- Append-only event log as the source of truth for analytics rollups
- Retry with backoff and a dead-letter queue for poison messages
What it is deliberately not
- Not a mass-mailer or spam tool — sending is capped, rate-limited and demo-scoped
- Not a real ESP replacement — it integrates one (SES) behind a provider-agnostic interface
- Not multi-provider by default — the abstraction exists, only one adapter ships
- Not a WYSIWYG email builder — templates use a simple HTML + merge-field editor
3. Technology Stack
Frontend
- React 18 + TypeScript
- React Query (server state, cache invalidation)
- Tailwind CSS
- Recharts (funnel, time-series and pie charts for analytics)
- React Hook Form + Zod (field-level validation)
Backend
- NestJS (REST, modular DI, pipes/guards/interceptors)
- MongoDB + Mongoose (schemas, transactions on multi-doc writes)
- class-validator / class-transformer DTOs
- JWT access + refresh tokens, Passport strategies
- BullMQ-compatible SQS consumer for worker processes
AWS
- SES — outbound sending + bounce/complaint feedback via SNS
- SQS — send-queue, webhook-queue, and both DLQs
- Lambda + API Gateway — the NestJS API itself, plus the webhook processor and scheduled-campaign trigger (no always-on compute for a low-traffic demo)
- S3 — recipient CSV imports/exports, template assets
- CloudWatch — structured logs, alarms on DLQ depth & bounce rate
- CloudFront — CDN for the React SPA build
Quality & delivery tooling
- Jest (unit) + Supertest (integration, `mongodb-memory-server`)
- Swagger / OpenAPI 3 (auto-generated from decorators)
- Docker + docker-compose (API, worker, Mongo, Mailhog, LocalStack)
- ESLint + Prettier + Husky pre-commit hooks
- GitHub Actions CI (lint → test → build → docker image)
4. Repository & Folder Structure
email-campaign-delivery-tracker/ ├── backend/ # NestJS API + worker │ └── src/ │ ├── main.ts # bootstrap, global pipes/filters/interceptors, Swagger mount │ ├── worker.ts # separate entrypoint for the SQS consumer process │ ├── config/ # env schema (Joi), typed ConfigModule providers │ ├── common/ # cross-cutting: filters, interceptors, guards, decorators, pipes │ │ ├── filters/http-exception.filter.ts │ │ ├── interceptors/logging.interceptor.ts │ │ ├── guards/jwt-auth.guard.ts │ │ ├── guards/roles.guard.ts │ │ └── decorators/current-user.decorator.ts │ ├── database/ # Mongoose connection module, index bootstrap script │ ├── modules/ │ │ ├── auth/ # login, refresh, register, guards, JWT strategy │ │ ├── organizations/ # org profile, sender domain settings │ │ ├── users/ # team members, roles │ │ ├── templates/ # reusable HTML email templates │ │ ├── contacts/ # contact lists + contacts + CSV import │ │ ├── campaigns/ # campaign CRUD, schedule, send, analytics │ │ ├── sending/ # EmailService (SES adapter), SendQueue producer/consumer │ │ ├── webhooks/ # provider webhook ingestion, signature verification, dedup │ │ ├── tracking/ # public open-pixel + click-redirect + unsubscribe endpoints │ │ ├── events/ # append-only event log + analytics rollups │ │ ├── suppressions/ # global suppression list │ │ └── audit/ # audit log writer + reader │ └── shared/ # DTOs, enums, interfaces shared across modules │ └── test/ # e2e (Supertest) specs, one per module │ ├── frontend/ # React + TypeScript SPA │ └── src/ │ ├── app/ # router, providers, layout shell │ ├── features/ │ │ ├── auth/ │ │ ├── dashboard/ │ │ ├── campaigns/ # list, wizard (create/edit), detail/analytics │ │ ├── templates/ │ │ ├── contacts/ # lists + contact tables + CSV import │ │ ├── suppressions/ │ │ └── settings/ │ ├── components/ # shared, presentational-only (Button, DataTable, StatCard, StateBadge...) │ ├── lib/ # apiClient (axios + interceptors), queryClient, formatters │ └── types/ # API response types, generated or hand-mirrored from DTOs │ ├── shared/ # types/constants imported by BOTH frontend and backend (delivery-status enum, error codes) │ ├── infra/ # deployment-related, kept out of application code │ ├── docker/ # Dockerfile.api, Dockerfile.worker, Dockerfile.web │ ├── docker-compose.yml # api + worker + mongo + mailhog + localstack, one command up │ ├── aws/ # CDK or Terraform for SQS/S3/SES/CloudWatch (documented, optional to run) │ └── github-actions/ # CI pipeline yaml │ ├── docs/ # see §32 — API docs, ERD, runbooks, screenshots ├── .github/ ├── LICENSE # MIT ├── TRADEMARK.md # Arsi India Info name/logo notice — see §31 └── README.md
5. Coding Standards & Conventions
These rules apply uniformly across every module in §4 — they are what keeps a ten-module NestJS app reviewable rather than a pile of one-off controllers.
Separation of concerns
- Controller — HTTP concerns only: route, DTO binding, guard/role decoration. No business logic.
- Service — business rules, orchestration, transaction boundaries. Framework-agnostic where practical.
- Repository (Mongoose model wrapper) — the only layer that talks to MongoDB. Services never import a raw Mongoose model directly outside their own module.
- DTOs — one per direction (`CreateCampaignDto`, `CampaignResponseDto`); response DTOs strip internal fields (e.g. `_id` → `id`, no `__v`).
Reusable building blocks
- `PaginationQueryDto` + `PaginatedResponse<T>` reused by every list endpoint (§24)
- `OrgScopedRepository` base class auto-injects `organizationId` into every query (§6)
- `EmailProvider` interface with a single `SesEmailProvider` implementation — swappable without touching call sites
- Shared `StateBadge`, `DataTable`, `EmptyState`, `ConfirmDialog` React components used by every screen in §22
Validation
- Every incoming payload is a `class-validator` DTO; a global `ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })` strips/rejects unknown fields
- Cross-field/business rules (e.g. "scheduledAt must be in the future") live in the service layer, not the DTO
- Frontend re-declares the same constraints with Zod so invalid input never reaches the network call — see §23
Exception handling
- Domain errors are thrown as typed exceptions (`CampaignNotFoundException extends NotFoundException`) carrying a stable `errorCode` (§13)
- A single global `HttpExceptionFilter` converts every thrown error — domain, validation, or unexpected — into the standard envelope (§12)
- Unexpected errors are logged with a correlation id and returned as `500 INTERNAL_ERROR` with no stack trace leaked to the client
Security practices
- Helmet, CORS allow-list, and a per-IP + per-org rate limiter (`@nestjs/throttler`) on all routes, tighter on `/auth/*` and public tracking routes
- Passwords hashed with bcrypt (cost 12); JWT access tokens short-lived (15 min), refresh tokens rotated and stored hashed
- Every inbound webhook is verified against the provider's signing secret before its payload is trusted (§9)
- Secrets (JWT secret, SES/SNS credentials, webhook signing secrets) come only from environment variables / AWS Secrets Manager — never committed
- Mongo queries never interpolate user input into `$where`; Mongoose's built-in operator whitelisting plus DTO validation closes the NoSQL-injection surface
Logging & configuration
- Structured JSON logging (pino) with a request-scoped correlation id propagated into worker logs for the same campaign/event
- No `console.log` in application code — a lint rule enforces the logger
- Config is loaded once via a typed `ConfigModule` validated against a Joi schema at boot; missing required env vars fail startup immediately rather than at first use
- Per-environment `.env.example` files documented in §32; no environment-specific branching in business logic
6. Organization Isolation & RBAC
Every account belongs to exactly one Organization (workspace). All campaign, template, contact and analytics data is scoped to `organizationId`, mirroring how a real ESP separates customers.
6.1 How an organization is established
- Registering creates a new `Organization` and a first user with role OWNER.
- The JWT access token embeds `{ sub: userId, organizationId, role }`.
- A `JwtAuthGuard` decodes the token; an `OrgScopeInterceptor` attaches `organizationId` to the request context.
- Every repository method extends `OrgScopedRepository`, which injects `{ organizationId }` into every `find`, `updateOne`, and `deleteOne` filter — a resource belonging to another organization is architecturally unreachable, not just permission-checked.
404 (not a 403, so existence of another org's data is never leaked).
6.2 Roles
| Role | Can view | Can create/edit | Can send campaigns | Can manage users/settings |
|---|---|---|---|---|
| OWNER | Everything | Everything | Yes | Yes |
| ADMIN | Everything | Everything | Yes | Yes (except billing/ownership transfer) |
| MARKETER | Everything | Campaigns, templates, lists | Yes | No |
| ANALYST | Everything | No | No | No |
Enforced with a `@Roles(...)` decorator + `RolesGuard` read from the same JWT claim — no extra DB round-trip per request.
7. Database Schema (MongoDB)
7.1 Collection inventory
| Collection | Purpose | Key indexes |
|---|---|---|
organizations | Workspace profile, verified sender domain/email | { slug: 1 } unique |
users | Team members, credentials, role | { organizationId: 1, email: 1 } unique |
templates | Reusable HTML/text email templates | { organizationId: 1, name: 1 } |
contact_lists | Named recipient lists ("segments") | { organizationId: 1 } |
contacts | Individual recipients + list membership + status | { organizationId: 1, email: 1 } unique, { listIds: 1 } |
campaigns | Campaign definition, schedule, status, rollup stats, version for optimistic locking | { organizationId: 1, status: 1 } |
campaign_recipients | One row per (campaign, contact) — per-recipient delivery status + tracking token | { campaignId: 1, status: 1 }, { trackingToken: 1 } unique |
events | Append-only tracking event log — the source of truth for analytics | { campaignId: 1, type: 1, occurredAt: 1 }, { campaignRecipientId: 1 } |
suppressions | Org-wide do-not-send list (bounced/complained/unsubscribed/manual) | { organizationId: 1, email: 1 } unique |
webhook_logs | Raw inbound webhook payloads — idempotency dedup + replay/audit | { payloadHash: 1 } unique |
An audit_logs collection (§10) rounds this out to ten. Field sets are intentionally narrow — no custom-field builder, no multi-currency, no A/B testing framework; those are exactly the kind of enterprise features a portfolio demo should skip (see §2).
7.2 Representative schemas
// campaigns collection (Mongoose schema, abbreviated)
{
organizationId: ObjectId, // tenant scope — see §6
name: String, // required, 3-120 chars
subject: String, // required, 3-200 chars
fromName: String,
fromEmail: String, // must match org's verified sender domain
templateId: ObjectId, // ref: templates
listIds: [ObjectId], // ref: contact_lists, min 1
status: String, // DRAFT|SCHEDULED|SENDING|SENT|PAUSED|CANCELLED — §8
scheduledAt: Date, // null for immediate send
sentAt: Date,
stats: { // denormalized rollup, rebuilt from events (§7.3)
queued: Number, sent: Number, delivered: Number, opened: Number,
clicked: Number, bounced: Number, complained: Number,
failed: Number, unsubscribed: Number
},
version: Number, // optimistic lock, incremented on every update
createdBy: ObjectId,
createdAt: Date, updatedAt: Date
}
// events collection — append-only, never updated or deleted
{
organizationId: ObjectId,
campaignId: ObjectId,
campaignRecipientId: ObjectId,
type: String, // QUEUED|SENT|DELIVERED|OPENED|CLICKED|BOUNCED|COMPLAINED|FAILED|UNSUBSCRIBED — §8.2
source: String, // worker|webhook|tracking-pixel|tracking-link|manual
occurredAt: Date,
meta: { ip: String, userAgent: String, url: String, bounceType: String },
providerEventId: String // dedup key from the ESP, unique per (campaignRecipientId, type, providerEventId)
}
7.3 Rollups are derived, not hand-maintained
campaigns.stats is a cache. The events collection is the single source of truth; a rollup worker recomputes `stats` via an aggregation pipeline whenever a new event lands for that campaign, and the nightly reconciliation job in §29 re-derives all rollups from scratch to catch drift. This mirrors the reference plan's audit-history principle: never let a mutable summary field be the only record of what happened.
8. Campaign & Delivery State Machines
8.1 Campaign lifecycle (per campaign)
8.2 Per-recipient delivery pipeline (one state machine per campaign_recipients row)
Transitions only ever move forward or into a terminal side-state — no event handler ever downgrades a recipient from CLICKED back to SENT, even if webhook events arrive out of order (enforced by an explicit state-rank check in EventsService.applyEvent()).
9. Queues, Retries & Webhook Security
9.1 Retry & dead-letter handling
- Both queues use visibility timeout + exponential backoff; a message is retried up to 5 times before moving to its dedicated DLQ.
- A CloudWatch alarm fires when either DLQ depth > 0, paging the on-call channel (Slack webhook in the demo).
- DLQ messages can be replayed via an internal admin script that re-enqueues them onto the source queue after the root cause is fixed.
9.2 Webhook signature verification
// webhooks.controller.ts — simplified @Post('ses') async handleSesWebhook(@Req() req: RawBodyRequest<Request>) { const signature = req.headers['x-amz-sns-signature']; if (!this.snsVerifier.isValid(req.rawBody, signature)) { throw new UnauthorizedException('INVALID_WEBHOOK_SIGNATURE'); } const payloadHash = sha256(req.rawBody); const alreadySeen = await this.webhookLogs.existsByHash(payloadHash); if (alreadySeen) return { accepted: true, duplicate: true }; // idempotent no-op await this.webhookLogs.record(payloadHash, req.rawBody); await this.sendQueue.enqueueWebhookEvent(req.body); return { accepted: true }; }
9.3 Duplicate-event protection
Two independent layers, matching how real ESPs actually misbehave: (1) the raw webhook payload is hashed and stored in webhook_logs before processing — an identical retry from the provider is dropped at the door; (2) inside processing, each derived events document has a unique compound index on (campaignRecipientId, type, providerEventId) — even a semantically-duplicate-but-differently-wrapped payload cannot double-count an open or a click.
10. Audit History & Logging
Every create/update/delete on campaigns, templates, contact_lists, and organization settings writes an audit_logs entry: { organizationId, userId, action, entityType, entityId, before, after, createdAt }. Combined with the immutable events collection (§7.3), the platform never has to guess what happened — only replay it. Application logs are structured JSON (pino), tagged with a request correlation id that is also attached to any SQS message the request produces, so a single campaign send can be traced end-to-end across the API process and the worker process in CloudWatch Logs Insights.
11. API Design Conventions
Base path & versioning
All authenticated routes are prefixed /api/v1. Public tracking routes (§18) are intentionally short and unversioned (/t/o/:token, /t/c/:token) since they are embedded in already-sent emails and must never break.
Auth header
Authorization: Bearer <accessToken> on every route except /auth/login, /auth/register, /auth/refresh, and the public tracking/unsubscribe routes.
Naming & verbs
Resources are plural nouns (/campaigns); actions that aren't pure CRUD are sub-resource verbs on the resource: POST /campaigns/:id/schedule, POST /campaigns/:id/send-test, never a verb in the base path.
Field minimalism
Response DTOs return only fields the corresponding screen (§22) actually renders — no raw Mongo documents, no internal worker bookkeeping fields, no __v.
12. Response & Error Envelope
12.1 Success — single resource
{
"success": true,
"data": { "id": "66f1...", "name": "Spring Sale Launch", "status": "DRAFT" }
}
12.2 Success — paginated list
{
"success": true,
"data": [ /* array of resources */ ],
"meta": { "page": 1, "limit": 20, "total": 143, "totalPages": 8 }
}
12.3 Error
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Request payload failed validation.",
"details": [ { "field": "subject", "message": "subject must be between 3 and 200 characters" } ]
}
}
13. Error Code Catalog
| HTTP | Code | Meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | Payload failed DTO validation — see error.details for field-level messages |
| 400 | INVALID_STATE_TRANSITION | e.g. attempting to send a campaign that is not in DRAFT/SCHEDULED |
| 401 | UNAUTHORIZED | Missing or expired access token |
| 401 | INVALID_WEBHOOK_SIGNATURE | Inbound webhook failed signature verification (§9.2) |
| 403 | FORBIDDEN_ROLE | Authenticated, but role lacks permission for this action |
| 404 | CAMPAIGN_NOT_FOUND | No campaign with that id in the caller's organization |
| 404 | TEMPLATE_NOT_FOUND | Referenced templateId does not exist in this organization |
| 404 | LIST_NOT_FOUND | Referenced contact list does not exist in this organization |
| 409 | DUPLICATE_NAME | A campaign/template/list with that name already exists in this organization |
| 409 | VERSION_CONFLICT | Optimistic lock failed — the record was modified since it was read |
| 409 | SUPPRESSED_RECIPIENT | Attempting to add a globally-suppressed address back into an active list without an explicit override |
| 422 | SENDER_NOT_VERIFIED | Campaign's fromEmail domain is not a verified SES sender identity |
| 429 | RATE_LIMITED | Too many requests from this org/IP in the current window |
| 500 | INTERNAL_ERROR | Unexpected failure — logged with a correlation id, no internals leaked to the client |
14. API — Auth & Users
POST/api/v1/auth/register
Public Creates a new Organization and its first user (OWNER).
| Auth | None (public route, rate-limited to 10/hour/IP) |
| Validation | organizationName 2-80 chars; email valid + unique across all users; password min 10 chars, at least 1 number |
| DB interaction | Inserts one organizations doc and one users doc in a single Mongo transaction (both-or-neither) |
| Success | 201 Created — user + org summary + access/refresh tokens |
| Errors | 400 VALIDATION_ERROR, 409 DUPLICATE_NAME (email already registered) |
// Request { "organizationName": "NovaMail Retail Co.", "name": "Asha Rao", "email": "asha@novamail.demo", "password": "Str0ngPass!23" } // 201 Response { "success": true, "data": { "user": { "id": "66f...", "role": "OWNER" }, "organization": { "id": "66f...", "name": "NovaMail Retail Co." }, "accessToken": "eyJ...", "refreshToken": "eyJ..." } }
POST/api/v1/auth/login
Public Authenticates a user and issues tokens.
| Auth | None (rate-limited to 10/min/IP to slow credential stuffing) |
| Validation | email valid format, password non-empty |
| DB interaction | Reads users by email, bcrypt-compares password hash; on success stores hashed refresh token |
| Success | 200 OK — access/refresh tokens + user profile |
| Errors | 400 VALIDATION_ERROR, 401 UNAUTHORIZED (generic "invalid credentials" — never reveals whether the email exists) |
POST/api/v1/auth/refresh
Public Exchanges a valid refresh token for a new access/refresh pair (rotation — the old refresh token is invalidated).
GET/api/v1/users/me
Any role Returns the caller's profile and organization summary.
GET/api/v1/users
ADMINOWNER Lists team members in the caller's organization (paginated, §24).
15. API — Campaigns & Templates
POST/api/v1/campaigns
MARKETER+ Creates a new campaign in DRAFT status.
| Auth | Bearer JWT, role MARKETER or above |
| Validation | name 3-120 chars, unique per org; subject 3-200 chars; templateId must exist in org; listIds non-empty array, each must exist in org; fromEmail domain must match a verified sender identity |
| DB interaction | Inserts into campaigns with status: DRAFT, version: 1; writes an audit_logs entry |
| Success | 201 Created + campaign object |
| Errors | 400 VALIDATION_ERROR, 404 TEMPLATE_NOT_FOUND, 404 LIST_NOT_FOUND, 409 DUPLICATE_NAME, 422 SENDER_NOT_VERIFIED |
GET/api/v1/campaigns
Paginated, filterable by status, searchable by name, sortable by createdAt|scheduledAt|name (§24).
GET/api/v1/campaigns/:id
Returns full campaign detail including current stats rollup. 404 CAMPAIGN_NOT_FOUND if absent or owned by another org.
PUT/api/v1/campaigns/:id
Edits a campaign. Allowed only while status = DRAFT; otherwise 400 INVALID_STATE_TRANSITION. Requires the client's last-seen version in the payload — mismatch returns 409 VERSION_CONFLICT (optimistic locking).
POST/api/v1/campaigns/:id/send-test
Sends the rendered campaign to up to 5 caller-supplied test addresses without creating campaign_recipients rows or affecting stats. Backed directly by the EmailProvider, bypassing the queue for immediate feedback.
POST/api/v1/campaigns/:id/schedule
| Auth | Bearer JWT, role MARKETER or above |
| Validation | scheduledAt required, must be a future ISO-8601 timestamp; campaign must currently be DRAFT |
| DB interaction | Sets status: SCHEDULED, scheduledAt; a CloudWatch Events rule (or immediate enqueue if scheduledAt is omitted) triggers the send at the target time |
| Success | 200 OK + updated campaign |
| Errors | 400 VALIDATION_ERROR, 400 INVALID_STATE_TRANSITION, 422 SENDER_NOT_VERIFIED |
POST/api/v1/campaigns/:id/cancel
Allowed from DRAFT, SCHEDULED, or PAUSED only. In-flight recipient sends already dispatched to SES are not recalled — only not-yet-dequeued jobs are dropped, and this distinction is shown to the user (§22.4).
GET/api/v1/campaigns/:id/recipients
Paginated per-recipient delivery status for one campaign — backs the recipient table in §22.4. Filterable by status.
Templates
| Method | Path | Roles | Purpose |
|---|---|---|---|
| POST | /api/v1/templates | MARKETER+ | Create a reusable HTML/text template with merge-field placeholders |
| GET | /api/v1/templates | Any | Paginated list |
| GET | /api/v1/templates/:id | Any | Full template body for the editor/preview |
| PUT | /api/v1/templates/:id | MARKETER+ | Update; blocked if the template is referenced by a non-DRAFT campaign |
| DELETE | /api/v1/templates/:id | ADMIN+ | Soft delete (blocked if referenced by any campaign) |
16. API — Recipient Lists & Contacts
POST/api/v1/lists/:id/import
Imports contacts from a CSV previously uploaded to S3 via a presigned URL (frontend uploads directly to S3; this endpoint only registers the object key for async processing — see §22.6).
| Auth | Bearer JWT, role MARKETER or above |
| Validation | s3Key required and must belong to this org's upload prefix; file must be .csv, max 25,000 rows, max 5MB |
| DB interaction | Enqueues an import job (SQS); worker streams the CSV from S3, upserts contacts by (organizationId, email), skips rows whose email is in suppressions, and writes an import summary back to the list document |
| Success | 202 Accepted + importJobId (frontend polls GET /lists/:id/imports/:jobId for progress — see §22.6 loading state) |
| Errors | 400 VALIDATION_ERROR (bad file type/size), 404 LIST_NOT_FOUND |
| Method | Path | Roles | Purpose |
|---|---|---|---|
| POST | /api/v1/lists | MARKETER+ | Create an empty named list |
| GET | /api/v1/lists | Any | Paginated list with contact counts |
| GET | /api/v1/lists/:id | Any | List detail |
| DELETE | /api/v1/lists/:id | ADMIN+ | Soft delete (blocked if referenced by a non-terminal campaign) |
| GET | /api/v1/lists/:id/contacts | Any | Paginated contacts within the list |
| POST | /api/v1/lists/:id/contacts | MARKETER+ | Add a single contact manually (rejects with 409 SUPPRESSED_RECIPIENT unless override:true) |
| DELETE | /api/v1/lists/:id/contacts/:contactId | MARKETER+ | Remove a contact from this list only (does not delete the contact record) |
17. API — Sending & Webhook Ingestion
POST/api/v1/webhooks/ses
Public, signature-verified Receives SNS-wrapped SES delivery notifications (delivery, bounce, complaint). See §9.2 for the verification/dedup logic.
| Auth | None — trust is established via HMAC/SNS message signature, not a bearer token |
| Validation | SNS envelope schema; unrecognized notificationType values are accepted and logged (not rejected) so provider additions don't cause dropped webhooks |
| DB interaction | Writes to webhook_logs (dedup), enqueues onto webhook-queue; the consumer inserts an events row, advances the matching campaign_recipients.status, and — for bounce/complaint — upserts into suppressions |
| Success | 200 OK always (ESPs disable/backoff a webhook endpoint that returns errors) — failures are handled internally via the queue's own retry/DLQ, never surfaced as a non-2xx to the provider |
| Errors | 401 INVALID_WEBHOOK_SIGNATURE is the only rejection this endpoint ever returns |
// Inbound SNS-wrapped SES bounce notification (abbreviated) { "notificationType": "Bounce", "mail": { "messageId": "0102018f...", "destination": ["john@demo-inbox.test"] }, "bounce": { "bounceType": "Permanent", "bounceSubType": "General" } }
18. API — Public Tracking & Unsubscribe
GET/t/o/:token
Public 1×1 transparent GIF embedded in every sent email. A hit records an OPENED event.
| Auth | None — :token is the unguessable campaign_recipients.trackingToken (UUID) |
| Validation | Token must resolve to an existing campaign_recipients row; unknown tokens still return the pixel (never a 404, to avoid leaking valid-token structure and to keep email clients happy) |
| DB interaction | Idempotent insert into events (first OPENED per recipient advances status; repeat opens are logged as additional events for analytics but do not change campaign_recipients.status once already OPENED/CLICKED) |
| Success | 200 OK, Content-Type: image/gif, 43-byte transparent pixel, always — this route never errors visibly |
GET/t/c/:token
Public Every link in an email body is rewritten to point here at send time; records a CLICKED event (also implies OPENED if not already recorded) and 302-redirects to the original destination URL stored alongside the token mapping.
POST/api/v1/unsubscribe/:token
Public Called by the one-click unsubscribe confirmation page (§22.9). Adds the recipient's email to suppressions (reason: unsubscribed) and records an UNSUBSCRIBED event. Idempotent — unsubscribing twice is a no-op success.
19. API — Analytics & Reporting
GET/api/v1/analytics/overview
Org-wide dashboard totals across a date range (default: last 30 days) — backs §22.2.
| Auth | Bearer JWT, any role |
| Validation | Optional from/to query params, ISO-8601 dates, from ≤ to, range capped at 12 months |
| DB interaction | Mongo aggregation pipeline over events grouped by type, scoped to organizationId and the date range |
| Success | 200 OK + totals for sent/delivered/opened/clicked/bounced/complained/failed/unsubscribed and derived rates (delivery rate, open rate, click rate, bounce rate) |
{ "success": true, "data": {
"totalSent": 10000, "delivered": 9620, "opened": 5120, "clicked": 1240,
"bounced": 280, "complained": 14, "failed": 100, "unsubscribed": 63,
"deliveryRate": 96.2, "openRate": 53.2, "clickRate": 12.9, "bounceRate": 2.8
} }
GET/api/v1/analytics/campaigns/:id
Same shape, scoped to one campaign, plus a time-bucketed series (hourly for the first 48h) for the opens/clicks-over-time chart in §22.4.
20. Full Endpoint Index
| Method | Path | Roles | Purpose |
|---|---|---|---|
| POST | /api/v1/auth/register | Public | Create organization + owner user |
| POST | /api/v1/auth/login | Public | Authenticate, issue tokens |
| POST | /api/v1/auth/refresh | Public | Rotate access/refresh tokens |
| POST | /api/v1/auth/logout | Any | Revoke current refresh token |
| GET | /api/v1/users/me | Any | Current user profile |
| GET | /api/v1/users | ADMIN+ | List team members |
| POST | /api/v1/users/invite | ADMIN+ | Invite a team member by email |
| GET | /api/v1/organizations/me | Any | Org profile + sender domain status |
| PUT | /api/v1/organizations/me | ADMIN+ | Update org profile / sender identity |
| POST | /api/v1/campaigns | MARKETER+ | Create draft campaign |
| GET | /api/v1/campaigns | Any | Paginated campaign list |
| GET | /api/v1/campaigns/:id | Any | Campaign detail + stats |
| PUT | /api/v1/campaigns/:id | MARKETER+ | Edit draft campaign |
| DELETE | /api/v1/campaigns/:id | ADMIN+ | Soft delete a draft campaign |
| POST | /api/v1/campaigns/:id/send-test | MARKETER+ | Send preview to up to 5 test addresses |
| POST | /api/v1/campaigns/:id/schedule | MARKETER+ | Schedule or immediately queue the send |
| POST | /api/v1/campaigns/:id/pause | MARKETER+ | Pause a SENDING campaign |
| POST | /api/v1/campaigns/:id/resume | MARKETER+ | Resume a PAUSED campaign |
| POST | /api/v1/campaigns/:id/cancel | MARKETER+ | Cancel not-yet-dispatched sends |
| GET | /api/v1/campaigns/:id/recipients | Any | Per-recipient delivery status |
| POST | /api/v1/templates | MARKETER+ | Create template |
| GET | /api/v1/templates | Any | Paginated template list |
| GET | /api/v1/templates/:id | Any | Template detail |
| PUT | /api/v1/templates/:id | MARKETER+ | Update template |
| DELETE | /api/v1/templates/:id | ADMIN+ | Soft delete template |
| POST | /api/v1/lists | MARKETER+ | Create recipient list |
| GET | /api/v1/lists | Any | Paginated list of lists |
| GET | /api/v1/lists/:id | Any | List detail |
| DELETE | /api/v1/lists/:id | ADMIN+ | Soft delete list |
| GET | /api/v1/lists/:id/contacts | Any | Paginated contacts |
| POST | /api/v1/lists/:id/contacts | MARKETER+ | Add one contact |
| DELETE | /api/v1/lists/:id/contacts/:contactId | MARKETER+ | Remove contact from list |
| POST | /api/v1/lists/:id/import | MARKETER+ | Register S3 CSV for async import |
| GET | /api/v1/lists/:id/imports/:jobId | Any | Import job progress |
| GET | /api/v1/suppressions | Any | Paginated suppression list |
| POST | /api/v1/suppressions | ADMIN+ | Manually suppress an address |
| DELETE | /api/v1/suppressions/:id | ADMIN+ | Remove a manual suppression (not bounce/complaint-based) |
| POST | /api/v1/webhooks/ses | Public (signed) | SES/SNS delivery notification ingestion |
| GET | /t/o/:token | Public | Open-tracking pixel |
| GET | /t/c/:token | Public | Click-tracking redirect |
| POST | /api/v1/unsubscribe/:token | Public | One-click unsubscribe |
| GET | /api/v1/analytics/overview | Any | Org-wide dashboard totals |
| GET | /api/v1/analytics/campaigns/:id | Any | Per-campaign funnel + time series |
21. Frontend Architecture
21.1 Data flow
21.2 State ownership rule
22. Screens & UI/UX Specification
Ten screens, each scoped to only the fields the corresponding business object actually needs (§2) — no vestigial "custom fields" builder, no multi-language editor, no drag-and-drop template designer (a plain HTML/merge-field editor is enough to prove the pipeline).
/login · /registerFields: Login — email* (email format), password* (min 10 chars). Register — organization name* (2-80 chars), your name*, email* (unique), password* (min 10 chars, 1 number, strength meter).
Validation: inline, on blur; submit disabled until the form is valid; server-side 409 DUPLICATE_NAME on register renders "An account with this email already exists — try signing in instead" with a link to /login.
States: loading (submit button spinner, disabled), error (form-level banner for 401 "Invalid email or password"), success (redirect to /dashboard).
Responsive: single centered card, full-width fields below 480px.
/dashboardOrg-wide KPI row (total sent, delivery rate, open rate, click rate, bounce rate — from §19), a "Recent Campaigns" table (name, status badge, sent date, opens, clicks, open rate), and a 30-day sends-over-time chart.
States: loading (skeleton KPI cards + skeleton table rows), empty ("You haven't sent a campaign yet" with a primary "Create your first campaign" CTA), error (retry banner, KPIs show "—" rather than 0 to avoid implying real zero activity).
/campaignsColumns: Name, Status badge, List(s), Scheduled/Sent date, Open rate, Click rate, Actions (Edit — DRAFT only, Duplicate, View analytics).
Filter/search: status filter chips, name search, sort by name/date (§24). Empty: "No campaigns match your filters" with a "Clear filters" link (distinct from the true-zero empty state on the dashboard). Confirmation: deleting a draft opens a confirm dialog ("This can't be undone"); deleting is disabled (tooltip explains why) for any non-draft campaign.
/campaigns/new · /campaigns/:idWizard steps: 1) Details (name*, subject*, from name*, from email* — dropdown of verified senders only) → 2) Recipients (select one or more lists*, live recipient count, suppressed-count callout) → 3) Content (pick template*, live HTML preview with sample merge-field values) → 4) Review & Schedule (send now or pick date/time*, "Send test email" action).
Field-level validation: each step blocks "Next" until its required fields are valid; the Review step re-validates everything server-side before enabling "Confirm" so a stale draft (e.g. a deleted template) is caught before send, not after.
Return/confirm behavior: "Confirm & Schedule" shows an inline confirm ("This will send to 4,812 recipients — continue?"); on success, redirects to the Detail/Analytics view for that campaign with a success toast.
Detail/Analytics view (once not DRAFT): funnel chart (Queued→Sent→Delivered→Opened→Clicked with side-counts for Bounced/Complained/Failed/Unsubscribed), opens/clicks-over-time chart, and the paginated recipient table from GET /campaigns/:id/recipients with a per-row status badge.
States: a SENDING campaign auto-refreshes stats every 15s (React Query polling) with a subtle "Live" indicator; loading = skeleton funnel; error = "Analytics temporarily unavailable" banner, retry button.
/templates · /templates/:idEditor fields: name* (unique per org), subject*, HTML body* (code editor with a live rendered preview pane), available merge fields shown as an insertable chip list ({{firstName}}, {{lastName}}). Validation: HTML body must include an unsubscribe merge tag before saving — enforced client-side with a clear message ("Every template needs an unsubscribe link — insert {{unsubscribeUrl}}") and server-side as a hard rule.
/lists · /lists/:idImport flow: drag-and-drop or browse a CSV → client validates header row maps to email/firstName/lastName → uploads directly to S3 via a presigned URL → registers the import (§16) → progress bar polls job status → completion summary ("4,812 imported, 38 skipped — already suppressed", with a downloadable skip report).
States: empty list ("Import your first contacts"), uploading (progress %), processing (indeterminate bar + "Usually takes under a minute"), error (per-row error CSV download link).
/suppressionsRead-mostly table: email, reason (bounced/complained/unsubscribed/manual) badge, date added. Manually adding one requires a confirm dialog explaining it will silently exclude that address from all future sends.
/settingsTabs: Organization (name, sender domain verification status pill), Team (invite/remove users, change roles — ADMIN+ only), API/Webhooks (view-only in the demo: the SES/SNS webhook URL to configure, with a "copy" button).
/unsubscribe/:token — no auth, no app shellMinimal branded page: "You've been unsubscribed from NovaMail Retail Co. emails." with a one-line "resubscribe" link that is intentionally not offered by default (matches real-world one-click unsubscribe compliance — resubscription would require contacting the sender).
23. UI & Validation Standards
- Required fields are marked with a trailing
*and enforced with the same Zod schema on the client as the DTO on the server — never a client rule looser or stricter than the API. - Error messages are specific and actionable ("Subject must be 3–200 characters", not "Invalid input") and map 1:1 from the API's
error.details[].messagewhere present. - Loading state: skeleton placeholders matching the real layout, never a bare spinner for content-shaped regions.
- Empty state: always distinguishes "nothing exists yet" (with a creation CTA) from "nothing matches your filter" (with a clear-filters action) — never the same message for both.
- Destructive/irreversible actions (delete, cancel a sending campaign, manual suppression) always show a confirm dialog naming the specific record and consequence.
- Toasts confirm every successful mutation ("Campaign scheduled for Aug 28, 9:00 AM"); errors surface inline near the offending field first, with a toast only as a secondary signal for whole-request failures.
24. Pagination, Filtering & Sorting
GET /api/v1/campaigns?page=1&limit=20&status=SENT&search=spring&sort=scheduledAt&direction=desc
Every list endpoint shares one PaginationQueryDto (page ≥ 1 default 1, limit 1-100 default 20, optional search, resource-specific status/filter fields, sort restricted to an allow-listed field set per resource, direction asc|desc) and one PaginatedResponse<T> shape (§12.2) so the frontend's shared DataTable component works identically across Campaigns, Templates, Lists, Contacts, and Suppressions.
25. Testing Strategy
Backend
- Unit tests per service (Jest) — business rules, state-transition guards, rollup math
- Integration tests (Supertest +
mongodb-memory-server) per module, including the tenant-isolation guardrail test (§6.1) - Webhook tests replay recorded SES/SNS payload fixtures, including a duplicate-delivery fixture to assert idempotency (§9.3)
- Worker tests run the SQS consumer against a local queue (LocalStack) end-to-end: enqueue → process → assert
events+campaign_recipientsstate
Frontend
- Component tests (React Testing Library) for the wizard's step-gating logic and form validation messages
- MSW (Mock Service Worker) mocks the API for component tests — no real network calls
- A small Playwright smoke suite: register → create campaign → send test → view analytics
26. Swagger / OpenAPI
Generated from NestJS decorators (@ApiTags, @ApiOperation, @ApiResponse, DTO @ApiProperty) and served at /api/docs in non-production environments. Every DTO documents its validation constraints inline so Swagger's "Try it out" reflects the same rules as §11–§19. The Swagger `info` block carries the branding fields described in §31.2.
27. Docker & Local Development
# infra/docker-compose.yml services api # NestJS HTTP server, port 3000 worker # same image, different entrypoint (worker.ts) — consumes SQS mongo # MongoDB 7 mailhog # local SMTP catcher — EmailProvider swaps to SmtpEmailProvider in dev, so no real email ever leaves a developer machine localstack # emulates SQS + S3 + SNS locally, no AWS account needed for local dev
EmailProvider resolves to Mailhog, never real SES — synthetic seed contacts (*.demo / *.test domains) can never accidentally receive a real email, satisfying the "no real customer data, no real sends" portfolio rule.
docker compose up brings up the full stack; npm run seed loads the fictional company "NovaMail Retail Co." with sample contacts, templates, and three campaigns in different lifecycle states for screenshots.
28. AWS Deployment Architecture
Deployment infra is documented (Terraform/CDK snippets under infra/aws/) but treated as optional for a reviewer — the Docker Compose stack in §27 is the primary "clone and run" path; AWS deployment is a secondary, fully-scripted path for anyone who wants to see it live.
29. Build Phases & Roadmap
- Repo scaffolding, ESLint/Prettier/Husky
- Auth + org + user modules
- Docker Compose (Mongo, Mailhog, LocalStack)
- Templates, Lists, Contacts + CSV import
- Campaign CRUD + optimistic locking
- EmailProvider abstraction + SES/SMTP adapters
- Send queue + worker, test-send, schedule/pause/cancel
- Open pixel, click redirect, unsubscribe
- Webhook ingestion, signature verify, dedup, suppressions
- Rollup worker, analytics endpoints
- Dashboard + campaign funnel UI + charts
- Rate limiting, audit log, DLQ + alarms
- Unit/integration/e2e test pass, Swagger polish
- README, screenshots, architecture diagram export
- Seed data, demo script, optional AWS deploy walkthrough
30. Risks & Mitigations
| Risk | Mitigation |
|---|---|
| Real-looking demo could send to real inboxes if misconfigured | Non-prod always uses Mailhog; seed contacts use non-routable .demo/.test domains; a startup check refuses to boot with a real SES key outside a production env flag |
| Webhook replay/out-of-order delivery corrupts recipient status | Idempotency key + forward-only state-rank check (§8.2, §9.3) |
| Large CSV import blocks the API request thread | Import is always async via SQS worker, never processed inline (§16) |
Analytics aggregation slows as events grows | Denormalized campaigns.stats cache (§7.3) avoids a full aggregation on every dashboard load; nightly reconciliation job catches drift |
| Reviewer can't run AWS parts without an account | Docker Compose + LocalStack path is the default, documented first (§27); AWS is clearly marked optional |
31. Copyright, Trademark & Branding
Author of record for this repository and every file in it.
31.1 Two-layer licensing
Source code is released under the MIT License (permissive, encourages reuse for learning). The Arsi India Info name and logo are not part of that grant — they are covered by a separate TRADEMARK.md notice: forks and derivatives are welcome, but must not present themselves as Arsi India Info's own product or reuse its logo/branding.
31.2 Where the signature lives
| Surface | Branding element |
|---|---|
| Frontend | Persistent <BrandFooter/> component on every page: "© 2026 Arsi India Info" |
| API | X-Powered-By: Arsi-India-Info response header on every route |
| API | GET /api/v1/about — public endpoint returning project + author metadata |
| Swagger | info.contact and info.license fields point to Arsi India Info |
| Source files | Copyright header banner in every file, enforced by a CI license-header-check step |
| This document | Sidebar, hero seal, and footer (below) — see §31.3 for the honesty note on watermarking |
31.3 Honest limits of watermarking a public repo
A watermark or footer string can always be stripped by someone who forks the code — it deters casual re-badging, it does not prevent a determined bad actor. Real proof of authorship for a portfolio comes from GitHub itself: commits under the Arsi India Info GitHub org, signed commits, a CODEOWNERS file, and SHA-256 checksums published against tagged releases. This plan treats visible branding as presentation, not enforcement.
32. Documentation Set & Portfolio Usage
| Document | Location | Covers |
|---|---|---|
| README.md | repo root | Elevator pitch, feature list, architecture diagram, screenshots, quick start |
| API docs | /api/docs (Swagger UI) + docs/api.md export | Every endpoint in §14–§20, request/response schemas, auth requirements |
| Frontend startup | docs/frontend-setup.md | npm install, .env.example, npm run dev, Storybook (optional) for shared components |
| Backend startup | docs/backend-setup.md | API + worker entrypoints, required env vars, seed script, Mongo index bootstrap |
| Testing guide | docs/testing.md | npm run test, test:e2e, test:integration, coverage thresholds |
| Deployment guide | docs/deployment.md | Docker Compose (primary) and AWS/Terraform (optional) — §27, §28 |
| Git/GitHub workflow | docs/contributing.md | Branch naming, Conventional Commits, PR template, required CI checks |
| Portfolio/demo usage | docs/portfolio-demo.md | A 5-minute walkthrough script: seed data, create campaign, watch it move through the funnel using the Mailhog UI as a stand-in inbox |
| ERD / schema reference | docs/data-model.md | Collection inventory (§7.1), relationships, index rationale |
33. Definition of Done
- All 29 endpoints in §20 implemented, validated, and covered by at least one integration test
- Every screen in §22 has loading, empty, error, and success states implemented — not just the happy path
- A full campaign lifecycle (create → schedule → send via Mailhog → webhook-simulated delivery/open/click/bounce → analytics reflect it) is demonstrable end-to-end with one seed script and no manual DB edits
- Tenant-isolation guardrail test (§6.1) and webhook idempotency test (§9.3) both pass in CI
- README, API docs, and the portfolio demo script (§32) are complete enough that someone who has never seen the repo can run it in under 10 minutes
- Branding present per the §31.2 table; LICENSE and TRADEMARK.md committed