Arsi India Info
Portfolio Build · Public GitHub Demo

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.

Repository
email-campaign-delivery-tracker
Author
R.M. — Arsi India Info
Category
Node.js · AWS · Webhooks · Background Processing
License
MIT (code) + Trademark Notice (brand)

1. Executive Summary

Ownership This project, including its source code, documentation and this plan, is authored and published by Arsi India Info. It is released publicly under the MIT License for portfolio and learning purposes; the Arsi India Info name and logo remain protected brand assets.

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.

10
Mongo Collections
29
API Endpoints
9
Delivery States
10
Screens
6
AWS Services

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

Decision
Feature-folder frontend, module-folder backend, shared domain vocabulary on both sides.
"campaigns" on the backend maps 1:1 to "campaigns" on the frontend, and to the "Campaigns" nav item, and to §15/§22 in this document.
Why: a reviewer (or future-me) can find everything about Campaigns by looking in one place per layer instead of hunting across a technical-layer-first tree.
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

  1. Registering creates a new `Organization` and a first user with role OWNER.
  2. The JWT access token embeds `{ sub: userId, organizationId, role }`.
  3. A `JwtAuthGuard` decodes the token; an `OrgScopeInterceptor` attaches `organizationId` to the request context.
  4. 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.
Guardrail Every integration test for a resource module includes a case that logs in as Org A and requests a record owned by Org B — asserting a 404 (not a 403, so existence of another org's data is never leaked).

6.2 Roles

RoleCan viewCan create/editCan send campaignsCan manage users/settings
OWNEREverythingEverythingYesYes
ADMINEverythingEverythingYesYes (except billing/ownership transfer)
MARKETEREverythingCampaigns, templates, listsYesNo
ANALYSTEverythingNoNoNo

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

CollectionPurposeKey indexes
organizationsWorkspace profile, verified sender domain/email{ slug: 1 } unique
usersTeam members, credentials, role{ organizationId: 1, email: 1 } unique
templatesReusable HTML/text email templates{ organizationId: 1, name: 1 }
contact_listsNamed recipient lists ("segments"){ organizationId: 1 }
contactsIndividual recipients + list membership + status{ organizationId: 1, email: 1 } unique, { listIds: 1 }
campaignsCampaign definition, schedule, status, rollup stats, version for optimistic locking{ organizationId: 1, status: 1 }
campaign_recipientsOne row per (campaign, contact) — per-recipient delivery status + tracking token{ campaignId: 1, status: 1 }, { trackingToken: 1 } unique
eventsAppend-only tracking event log — the source of truth for analytics{ campaignId: 1, type: 1, occurredAt: 1 }, { campaignRecipientId: 1 }
suppressionsOrg-wide do-not-send list (bounced/complained/unsubscribed/manual){ organizationId: 1, email: 1 } unique
webhook_logsRaw 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)

┌───────┐ ┌───────────┐ ┌─────────┐ ┌────────┐ │ DRAFT │──────▶│ SCHEDULED │──────▶│ SENDING │──────▶│ SENT │ └───────┘ └───────────┘ └─────────┘ └────────┘ │ │ ▲ │ │ ▼ │ resume │ any recipient error │ ┌─────────┐ ▼ │ │ PAUSED │ (recorded per-recipient, §8.2 — │ └─────────┘ campaign itself still reaches SENT) │ ▼ ┌────────────┐ │ CANCELLED │ (terminal — allowed only from DRAFT/SCHEDULED/PAUSED) └────────────┘

8.2 Per-recipient delivery pipeline (one state machine per campaign_recipients row)

┌────────┐ ┌──────┐ ┌───────────┐ ┌────────┐ ┌─────────┐ │ QUEUED │────▶│ SENT │────▶│ DELIVERED │────▶│ OPENED │────▶│ CLICKED │ └────────┘ └──────┘ └───────────┘ └────────┘ └─────────┘ │ │ │ │ invalid │ hard/soft │ recipient marks as spam │ address bounce ▼ ▼ ▼ ┌────────────┐ ┌────────┐ ┌─────────┐ │ COMPLAINED │ (immediately added to suppressions) │ FAILED │ │ BOUNCED │ └────────────┘ └────────┘ └─────────┘ recipient clicks the unsubscribe link at any point after SENT ───────────────────────────────────────────▶ UNSUBSCRIBED

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

React (SPA) │ ▼ NestJS API │ ├── Campaign Service — CRUD, schedule, stats read ├── Sending Service — builds send jobs, calls SES └── Webhook Service — verifies signature, dedups, enqueues │ │ ▼ ▼ SQS: send-queue SQS: webhook-queue │ │ ▼ ▼ Worker / Lambda Worker / Lambda (calls Amazon SES) (writes events, updates │ campaign_recipients + stats) ▼ │ Amazon SES ──(bounce/complaint via SNS)──▶ webhook-queue │ ▼ MongoDB ◀──────────────────────────────┘ (failed messages after 5 attempts → DLQ, alarmed via CloudWatch)

9.1 Retry & dead-letter handling

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

HTTPCodeMeaning
400VALIDATION_ERRORPayload failed DTO validation — see error.details for field-level messages
400INVALID_STATE_TRANSITIONe.g. attempting to send a campaign that is not in DRAFT/SCHEDULED
401UNAUTHORIZEDMissing or expired access token
401INVALID_WEBHOOK_SIGNATUREInbound webhook failed signature verification (§9.2)
403FORBIDDEN_ROLEAuthenticated, but role lacks permission for this action
404CAMPAIGN_NOT_FOUNDNo campaign with that id in the caller's organization
404TEMPLATE_NOT_FOUNDReferenced templateId does not exist in this organization
404LIST_NOT_FOUNDReferenced contact list does not exist in this organization
409DUPLICATE_NAMEA campaign/template/list with that name already exists in this organization
409VERSION_CONFLICTOptimistic lock failed — the record was modified since it was read
409SUPPRESSED_RECIPIENTAttempting to add a globally-suppressed address back into an active list without an explicit override
422SENDER_NOT_VERIFIEDCampaign's fromEmail domain is not a verified SES sender identity
429RATE_LIMITEDToo many requests from this org/IP in the current window
500INTERNAL_ERRORUnexpected 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).

AuthNone (public route, rate-limited to 10/hour/IP)
ValidationorganizationName 2-80 chars; email valid + unique across all users; password min 10 chars, at least 1 number
DB interactionInserts one organizations doc and one users doc in a single Mongo transaction (both-or-neither)
Success201 Created — user + org summary + access/refresh tokens
Errors400 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.

AuthNone (rate-limited to 10/min/IP to slow credential stuffing)
Validationemail valid format, password non-empty
DB interactionReads users by email, bcrypt-compares password hash; on success stores hashed refresh token
Success200 OK — access/refresh tokens + user profile
Errors400 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.

AuthBearer JWT, role MARKETER or above
Validationname 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 interactionInserts into campaigns with status: DRAFT, version: 1; writes an audit_logs entry
Success201 Created + campaign object
Errors400 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

AuthBearer JWT, role MARKETER or above
ValidationscheduledAt required, must be a future ISO-8601 timestamp; campaign must currently be DRAFT
DB interactionSets status: SCHEDULED, scheduledAt; a CloudWatch Events rule (or immediate enqueue if scheduledAt is omitted) triggers the send at the target time
Success200 OK + updated campaign
Errors400 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

MethodPathRolesPurpose
POST/api/v1/templatesMARKETER+Create a reusable HTML/text template with merge-field placeholders
GET/api/v1/templatesAnyPaginated list
GET/api/v1/templates/:idAnyFull template body for the editor/preview
PUT/api/v1/templates/:idMARKETER+Update; blocked if the template is referenced by a non-DRAFT campaign
DELETE/api/v1/templates/:idADMIN+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).

AuthBearer JWT, role MARKETER or above
Validations3Key required and must belong to this org's upload prefix; file must be .csv, max 25,000 rows, max 5MB
DB interactionEnqueues 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
Success202 Accepted + importJobId (frontend polls GET /lists/:id/imports/:jobId for progress — see §22.6 loading state)
Errors400 VALIDATION_ERROR (bad file type/size), 404 LIST_NOT_FOUND
MethodPathRolesPurpose
POST/api/v1/listsMARKETER+Create an empty named list
GET/api/v1/listsAnyPaginated list with contact counts
GET/api/v1/lists/:idAnyList detail
DELETE/api/v1/lists/:idADMIN+Soft delete (blocked if referenced by a non-terminal campaign)
GET/api/v1/lists/:id/contactsAnyPaginated contacts within the list
POST/api/v1/lists/:id/contactsMARKETER+Add a single contact manually (rejects with 409 SUPPRESSED_RECIPIENT unless override:true)
DELETE/api/v1/lists/:id/contacts/:contactIdMARKETER+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.

AuthNone — trust is established via HMAC/SNS message signature, not a bearer token
ValidationSNS envelope schema; unrecognized notificationType values are accepted and logged (not rejected) so provider additions don't cause dropped webhooks
DB interactionWrites 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
Success200 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
Errors401 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.

AuthNone — :token is the unguessable campaign_recipients.trackingToken (UUID)
ValidationToken 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 interactionIdempotent 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)
Success200 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.

AuthBearer JWT, any role
ValidationOptional from/to query params, ISO-8601 dates, fromto, range capped at 12 months
DB interactionMongo aggregation pipeline over events grouped by type, scoped to organizationId and the date range
Success200 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

MethodPathRolesPurpose
POST/api/v1/auth/registerPublicCreate organization + owner user
POST/api/v1/auth/loginPublicAuthenticate, issue tokens
POST/api/v1/auth/refreshPublicRotate access/refresh tokens
POST/api/v1/auth/logoutAnyRevoke current refresh token
GET/api/v1/users/meAnyCurrent user profile
GET/api/v1/usersADMIN+List team members
POST/api/v1/users/inviteADMIN+Invite a team member by email
GET/api/v1/organizations/meAnyOrg profile + sender domain status
PUT/api/v1/organizations/meADMIN+Update org profile / sender identity
POST/api/v1/campaignsMARKETER+Create draft campaign
GET/api/v1/campaignsAnyPaginated campaign list
GET/api/v1/campaigns/:idAnyCampaign detail + stats
PUT/api/v1/campaigns/:idMARKETER+Edit draft campaign
DELETE/api/v1/campaigns/:idADMIN+Soft delete a draft campaign
POST/api/v1/campaigns/:id/send-testMARKETER+Send preview to up to 5 test addresses
POST/api/v1/campaigns/:id/scheduleMARKETER+Schedule or immediately queue the send
POST/api/v1/campaigns/:id/pauseMARKETER+Pause a SENDING campaign
POST/api/v1/campaigns/:id/resumeMARKETER+Resume a PAUSED campaign
POST/api/v1/campaigns/:id/cancelMARKETER+Cancel not-yet-dispatched sends
GET/api/v1/campaigns/:id/recipientsAnyPer-recipient delivery status
POST/api/v1/templatesMARKETER+Create template
GET/api/v1/templatesAnyPaginated template list
GET/api/v1/templates/:idAnyTemplate detail
PUT/api/v1/templates/:idMARKETER+Update template
DELETE/api/v1/templates/:idADMIN+Soft delete template
POST/api/v1/listsMARKETER+Create recipient list
GET/api/v1/listsAnyPaginated list of lists
GET/api/v1/lists/:idAnyList detail
DELETE/api/v1/lists/:idADMIN+Soft delete list
GET/api/v1/lists/:id/contactsAnyPaginated contacts
POST/api/v1/lists/:id/contactsMARKETER+Add one contact
DELETE/api/v1/lists/:id/contacts/:contactIdMARKETER+Remove contact from list
POST/api/v1/lists/:id/importMARKETER+Register S3 CSV for async import
GET/api/v1/lists/:id/imports/:jobIdAnyImport job progress
GET/api/v1/suppressionsAnyPaginated suppression list
POST/api/v1/suppressionsADMIN+Manually suppress an address
DELETE/api/v1/suppressions/:idADMIN+Remove a manual suppression (not bounce/complaint-based)
POST/api/v1/webhooks/sesPublic (signed)SES/SNS delivery notification ingestion
GET/t/o/:tokenPublicOpen-tracking pixel
GET/t/c/:tokenPublicClick-tracking redirect
POST/api/v1/unsubscribe/:tokenPublicOne-click unsubscribe
GET/api/v1/analytics/overviewAnyOrg-wide dashboard totals
GET/api/v1/analytics/campaigns/:idAnyPer-campaign funnel + time series

21. Frontend Architecture

21.1 Data flow

React Component ──▶ React Query hook (useCampaigns, useCampaign, ...) │ ▼ lib/apiClient (axios, attaches Bearer token, refreshes on 401) │ ▼ NestJS REST API │ (cache invalidated on mutation success — e.g. creating a campaign invalidates the `['campaigns']` query key)

21.2 State ownership rule

Decision
Server state lives only in React Query's cache; component state (`useState`) is reserved for pure UI concerns (open dropdown, active wizard step, form draft).
No Redux/Zustand global store for data that the API already owns — it would just be a second, driftable copy of server truth.
Why: campaign stats can change from a webhook while the user has the tab open; a global store would need its own polling/invalidation logic that React Query already provides.

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).

22.1 Login / Register
/login · /register

Fields: 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.

22.2 Dashboard
/dashboard

Org-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).

┌─────────────────────────────────────────────────────────┐ │ Dashboard [+ New Campaign] │ ├─────────────────────────────────────────────────────────┤ │ [Sent] [Delivered] [Opened] [Clicked] [Bounced] │ │ 10,000 96.2% 53.2% 12.9% 2.8% │ ├─────────────────────────────────────────────────────────┤ │ Sends — last 30 days │ Recent Campaigns │ │ ▁▂▃▅▆▇█▆▅▃▂▁▂▃▅▆▇ │ Spring Sale Sent │ │ │ Cart Reminder Sent │ │ │ Newsletter #4 Draft │ └─────────────────────────────────────────────────────────┘
22.3 Campaigns List
/campaigns

Columns: 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.

22.4 Campaign Wizard (Create/Edit) & Detail/Analytics
/campaigns/new · /campaigns/:id

Wizard 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.

┌────────────────────────────────────────────────────────────┐ │ Spring Sale Launch [SENDING ●live] │ ├────────────────────────────────────────────────────────────┤ │ Queued Sent Delivered Opened Clicked │ │ 10,000 ▶ 9,940 ▶ 9,620 ▶ 5,120 ▶ 1,240 │ │ └ Bounced 280 └ Complained 14 │ ├────────────────────────────────────────────────────────────┤ │ Opens & Clicks — first 48h │ Recipients │ │ ▂▃▅▇█▇▅▃▂▁▁▁▁▁ │ a@x.com Opened │ │ │ b@x.com Delivered │ └────────────────────────────────────────────────────────────┘
22.5 Templates List & Editor
/templates · /templates/:id

Editor 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.

22.6 Recipient Lists & Contacts
/lists · /lists/:id

Import 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).

22.7 Suppression List
/suppressions

Read-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.

22.8 Settings
/settings

Tabs: 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).

22.9 Public Unsubscribe Confirmation
/unsubscribe/:token — no auth, no app shell

Minimal 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

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_recipients state

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
Demo safety In every non-production environment, the 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

┌────────────┐ │ CloudFront │──▶ S3 (React build) └────────────┘ │ ┌──────────────────────┐ Users ─────▶ │ API Gateway ─▶ Lambda │ └──────────────────────┘ │ │ ┌────────┘ └─────────┐ ▼ ▼ ┌──────────────┐ ┌──────────────┐ │ MongoDB Atlas │ │ SQS send-queue │ └──────────────┘ └──────────────┘ │ ▼ ┌──────────────────┐ │ Worker (Lambda) │──▶ Amazon SES └──────────────────┘ ▲ │ bounce/complaint (SNS) ┌──────────────────┐ │ SQS webhook-queue│ ◀── SES/SNS + click/open pixel hits └──────────────────┘ CloudWatch Logs/Alarms wraps every component above

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

Phase 0
Foundation
~4 days
  • Repo scaffolding, ESLint/Prettier/Husky
  • Auth + org + user modules
  • Docker Compose (Mongo, Mailhog, LocalStack)
Phase 1
Core domain
~1 week
  • Templates, Lists, Contacts + CSV import
  • Campaign CRUD + optimistic locking
Phase 2
Sending pipeline
~1 week
  • EmailProvider abstraction + SES/SMTP adapters
  • Send queue + worker, test-send, schedule/pause/cancel
Phase 3
Tracking & webhooks
~1 week
  • Open pixel, click redirect, unsubscribe
  • Webhook ingestion, signature verify, dedup, suppressions
Phase 4
Analytics
~4 days
  • Rollup worker, analytics endpoints
  • Dashboard + campaign funnel UI + charts
Phase 5
Hardening
~4 days
  • Rate limiting, audit log, DLQ + alarms
  • Unit/integration/e2e test pass, Swagger polish
Phase 6
Portfolio polish
~3 days
  • README, screenshots, architecture diagram export
  • Seed data, demo script, optional AWS deploy walkthrough

30. Risks & Mitigations

RiskMitigation
Real-looking demo could send to real inboxes if misconfiguredNon-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 statusIdempotency key + forward-only state-rank check (§8.2, §9.3)
Large CSV import blocks the API request threadImport is always async via SQS worker, never processed inline (§16)
Analytics aggregation slows as events growsDenormalized 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 accountDocker Compose + LocalStack path is the default, documented first (§27); AWS is clearly marked optional

31. Copyright, Trademark & Branding

Arsi India Info
Arsi India Info — Innovate · Integrate · Elevate.
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

SurfaceBranding element
FrontendPersistent <BrandFooter/> component on every page: "© 2026 Arsi India Info"
APIX-Powered-By: Arsi-India-Info response header on every route
APIGET /api/v1/about — public endpoint returning project + author metadata
Swaggerinfo.contact and info.license fields point to Arsi India Info
Source filesCopyright header banner in every file, enforced by a CI license-header-check step
This documentSidebar, 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

DocumentLocationCovers
README.mdrepo rootElevator pitch, feature list, architecture diagram, screenshots, quick start
API docs/api/docs (Swagger UI) + docs/api.md exportEvery endpoint in §14–§20, request/response schemas, auth requirements
Frontend startupdocs/frontend-setup.mdnpm install, .env.example, npm run dev, Storybook (optional) for shared components
Backend startupdocs/backend-setup.mdAPI + worker entrypoints, required env vars, seed script, Mongo index bootstrap
Testing guidedocs/testing.mdnpm run test, test:e2e, test:integration, coverage thresholds
Deployment guidedocs/deployment.mdDocker Compose (primary) and AWS/Terraform (optional) — §27, §28
Git/GitHub workflowdocs/contributing.mdBranch naming, Conventional Commits, PR template, required CI checks
Portfolio/demo usagedocs/portfolio-demo.mdA 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 referencedocs/data-model.mdCollection inventory (§7.1), relationships, index rationale

33. Definition of Done