Files
condado-newsletter/INSTRUCTIONS.md
Gabriel Sancho fa6731de98 Refactor project instructions and structure for monorepo setup
- Updated Step 1 to scaffold a monorepo structure for both backend and frontend.
- Renamed dependencies and adjusted project structure in INSTRUCTIONS.md.
- Added frontend dependencies and outlined the React application structure.
- Revised authentication method from API key to JWT for enhanced security.
- Created detailed instructions for frontend development, including page structure and API integration.
- Added steps for Docker configuration, including an all-in-one Docker image for deployment.
- Implemented CI/CD workflows for automated testing and Docker Hub publishing.
2026-03-26 14:31:25 -03:00

36 KiB

Build Instructions — Condado Abaixo da Média SA Email Bot

This file documents every step to build the project from scratch using AI. After each session, both this file and CLAUDE.md will be updated to reflect progress.


What Are We Building?

A group of friends created a fictional company called "Condado Abaixo da Média SA". Their dynamic works entirely over email — they write to each other in an extremely formal, corporate tone, but the content is completely casual and nonsensical (inside jokes, mundane topics, etc.). The contrast is the joke.

This service allows registering virtual employees of that fictional company. Each virtual employee is an AI-powered entity that:

  1. Has a name, email address, job title, and a personality description.
  2. Has a scheduled time to send emails (e.g., every Monday at 9am).
  3. Has a configurable context window (e.g., "read emails from the last 3 days") so it can react to what was already said in the thread.
  4. At the scheduled time: reads recent emails from the shared inbox via IMAP → builds a prompt → sends the prompt to the OpenAI API → sends the generated email via SMTP.

How to Use This File

  • Follow steps in order — each step builds on the previous one.
  • At the start of each AI session, share the contents of CLAUDE.md for context.
  • After completing each step, mark it Done and note any decisions made.
  • If anything changes (new library, schema change, etc.), update CLAUDE.md too.

Progress Tracker

Step Description Status
0 Define project & write CLAUDE.md Done
1 Scaffold monorepo structure Pending
2 Domain model (JPA entities) Pending
3 Repositories Pending
4 Email Reader Service (IMAP) Pending
5 Prompt Builder Service Pending
6 AI Service (OpenAI integration) Pending
7 Email Sender Service (SMTP) Pending
8 Scheduler (trigger per entity) Pending
9 REST Controllers & DTOs Pending
10 Authentication (JWT login) Pending
11 React Frontend Pending
12 Unit & Integration Tests Pending
13 Docker Compose (dev + prod) Pending
14 All-in-one Docker image Pending
15 CI/CD — GitHub Actions + Docker Hub Pending

Step 0 — Define the Project & Write CLAUDE.md

Goal: Establish the project scope and create the persistent AI instructions file.

What was done:

  • Understood the project concept: AI-driven fictional company employees that send formal-toned but casually-contented emails, reacting to each other via IMAP context reading.
  • Decided on Kotlin + Spring Boot 3.x as the core stack.
  • Chose PostgreSQL for persistence, Jakarta Mail for IMAP, Spring Mail for SMTP, and Gradle (Kotlin DSL) as the build tool.
  • Defined the core domain concepts: VirtualEntity, EmailContext, Prompt, DispatchLog.
  • Created CLAUDE.md with the prompt template, project structure, coding standards, and env vars.

Key decisions:

  • Use Gradle Kotlin DSL (build.gradle.kts) instead of Groovy DSL.
  • Use MockK for tests, not Mockito (more idiomatic for Kotlin).
  • Use OpenAI gpt-4o as the AI model (configurable via env var).
  • No Thymeleaf — emails are plain text or simple HTML generated entirely by the AI.
  • The prompt template is defined in CLAUDE.md and must be respected exactly.

Output files:

  • CLAUDE.md

Step 1 — Scaffold the Monorepo Structure

Goal: Create the full project skeleton for both backend and frontend, with all dependencies configured and the root-level Docker/CI files in place.

What the AI should create:

condado-news-letter/
├── .env.example
├── .gitignore
├── docker-compose.yml
├── docker-compose.prod.yml
├── Dockerfile.allinone
├── nginx/
│   └── nginx.conf
├── .github/
│   └── workflows/
│       ├── ci.yml
│       └── publish.yml
├── backend/
│   ├── Dockerfile
│   ├── build.gradle.kts
│   ├── settings.gradle.kts
│   ├── gradlew / gradlew.bat
│   ├── gradle/wrapper/
│   └── src/main/kotlin/com/condado/newsletter/
│       └── CondadoApplication.kt
│   └── src/main/resources/
│       ├── application.yml
│       └── application-dev.yml
│   └── src/test/kotlin/com/condado/newsletter/
│       └── CondadoApplicationTests.kt
└── frontend/
    ├── Dockerfile
    ├── package.json
    ├── vite.config.ts
    ├── tsconfig.json
    ├── index.html
    └── src/
        ├── main.tsx
        └── App.tsx

Backend dependencies (backend/build.gradle.kts):

Dependency Purpose
spring-boot-starter-web REST API
spring-boot-starter-data-jpa Database access (JPA/Hibernate)
spring-boot-starter-mail Email sending via SMTP
spring-boot-starter-validation DTO validation
spring-boot-starter-security JWT authentication
jjwt-api, jjwt-impl, jjwt-jackson JWT creation and validation (JJWT library)
postgresql PostgreSQL JDBC driver
angus-mail IMAP email reading (Jakarta Mail impl)
springdoc-openapi-starter-webmvc-ui Swagger UI / OpenAPI docs
kotlin-reflect Required by Spring for Kotlin
jackson-module-kotlin JSON serialization for Kotlin
h2 (testRuntimeOnly) In-memory DB for tests
spring-boot-starter-test JUnit 5 test support
mockk Kotlin mocking library
springmockk MockK integration for Spring

Frontend dependencies (frontend/package.json):

Package Purpose
react, react-dom Core React
typescript TypeScript
vite Build tool and dev server
@vitejs/plugin-react Vite React plugin
react-router-dom Client-side routing
@tanstack/react-query Server state management
axios HTTP client
tailwindcss, postcss, autoprefixer Styling
@radix-ui/*, shadcn/ui UI component library
lucide-react Icon library (used by shadcn)
vitest Test runner
@testing-library/react Component testing
@testing-library/jest-dom DOM matchers
jsdom Browser environment for tests

.env.example must contain all variables from the Environment Variables table in CLAUDE.md.

Prompt to use with AI:

"Using the CLAUDE.md context, scaffold the full monorepo. Create the backend Gradle project with all dependencies, the frontend Vite+React project with all packages, the root .env.example, .gitignore, placeholder docker-compose.yml, docker-compose.prod.yml, Dockerfile.allinone, nginx/nginx.conf, and GitHub Actions workflow stubs at .github/workflows/ci.yml and .github/workflows/publish.yml. Do not implement business logic yet — just the skeleton."

Done when:

  • cd backend && ./gradlew build compiles with no errors.
  • cd frontend && npm install && npm run build succeeds.
  • Application starts with ./gradlew bootRun (backend) without errors.
  • npm run dev starts the Vite dev server.
  • docker compose up --build starts all containers.


Step 2 — Domain Model (JPA Entities)

Goal: Create all database entities and their relationships.

Entities to create:

VirtualEntity

Represents a fictional employee of "Condado Abaixo da Média SA".

Column Type Notes
id UUID Primary key, auto-generated
name String The character's full name. Not null.
email String Sender email address. Unique, not null.
job_title String Job title in the fictional company. Not null.
personality Text Free-text personality description for the prompt.
schedule_cron String Cron expression for when to send emails.
context_window_days Int How many days back to read emails for context.
active Boolean Whether this entity is active. Default true.
created_at LocalDateTime Auto-set on creation.

DispatchLog

A record of every AI generation + email send attempt.

Column Type Notes
id UUID Primary key, auto-generated
entity_id UUID (FK) References VirtualEntity
prompt_sent Text The full prompt that was sent to the AI
ai_response Text The raw text returned by the AI
email_subject String The subject line parsed from the AI response
email_body Text The email body parsed from the AI response
status Enum PENDING / SENT / FAILED
error_message String Nullable — stores failure reason if FAILED
dispatched_at LocalDateTime When the dispatch was triggered

Prompt to use with AI:

"Using the CLAUDE.md context, create the two JPA entities: VirtualEntity and DispatchLog. Place them in the model/ package. Use UUIDs as primary keys and proper JPA annotations. DispatchLog has a @ManyToOne relationship to VirtualEntity."

Done when:

  • Both entity files exist in src/main/kotlin/com/condado/newsletter/model/.
  • ./gradlew build compiles cleanly.
  • Tables are auto-created by Hibernate on startup (ddl-auto: create-drop in dev).

Step 3 — Repositories

Goal: Create Spring Data JPA repositories for each entity.

Repository Entity Custom queries needed
VirtualEntityRepository VirtualEntity findAllByActiveTrue(), findByEmail()
DispatchLogRepository DispatchLog findAllByEntityId(), findTopByEntityIdOrderByDispatchedAtDesc()

Prompt to use with AI:

"Using the CLAUDE.md context, create the two JPA repositories in the repository/ package. Each must extend JpaRepository and include the custom query methods listed in the instructions."

Done when:

  • Both repository files exist in src/main/kotlin/com/condado/newsletter/repository/.
  • ./gradlew build compiles cleanly.

Step 4 — Email Reader Service (IMAP)

Goal: Read recent emails from the shared company inbox via IMAP to use as AI context.

Class: EmailReaderService in service/ package.

Responsibilities:

  • Connect to the IMAP server using credentials from environment variables.
  • Fetch all emails from the configured folder received within the last N days.
  • Return a list of EmailContext data objects, each containing:
    • from: String — sender name/address
    • subject: String — email subject
    • body: String — plain text body (strip HTML if needed)
    • receivedAt: LocalDateTime
  • Sort results from oldest to newest (chronological order, for natural prompt reading).
  • Handle IMAP errors gracefully — log the error and return an empty list rather than crashing.

Data class to create (in dto/ or model/):

data class EmailContext(
    val from: String,
    val subject: String,
    val body: String,
    val receivedAt: LocalDateTime
)

Prompt to use with AI:

"Using the CLAUDE.md context, create the EmailReaderService class in service/. It must connect to an IMAP server using env vars (IMAP_HOST, IMAP_PORT, MAIL_USERNAME, MAIL_PASSWORD, IMAP_INBOX_FOLDER), fetch emails from the last N days, and return them as a list of EmailContext data objects sorted chronologically. Use Jakarta Mail."

Done when:

  • EmailReaderService.kt exists in service/.
  • EmailContext.kt data class exists.
  • Service reads real emails when tested against a live IMAP account.
  • Returns empty list (not exception) on connection failure.

Step 5 — Prompt Builder Service

Goal: Transform a VirtualEntity + a list of EmailContext into a final AI prompt string.

Class: PromptBuilderService in service/ package.

Rule: This is the ONLY place in the codebase where prompt strings are built. No other class may construct or modify prompts.

The prompt template is defined in CLAUDE.md under "The Prompt Template (Core Logic)". It must be followed exactly, with the entity fields and email context filled in dynamically.

Method signature:

fun buildPrompt(entity: VirtualEntity, emailContext: List<EmailContext>): String

Prompt to use with AI:

"Using the CLAUDE.md context, create PromptBuilderService in service/. It must implement buildPrompt(entity, emailContext) following the prompt template defined in CLAUDE.md exactly. This is the only class allowed to build prompt strings."

Done when:

  • PromptBuilderService.kt exists in service/.
  • Output prompt matches the template from CLAUDE.md with fields correctly substituted.
  • Unit test verifies the prompt structure.

Step 6 — AI Service (OpenAI Integration)

Goal: Send the prompt to OpenAI and get back the generated email text.

Class: AiService in service/ package.

Responsibilities:

  • Call the OpenAI Chat Completions API (POST https://api.openai.com/v1/chat/completions).
  • Use the model configured in OPENAI_MODEL env var (default: gpt-4o).
  • Send the prompt as a user message.
  • Return the AI's response as a plain String.
  • Parse the response to extract subject and body — the AI should be instructed to return them in a structured format:
    SUBJECT: <generated subject>
    BODY:
    <generated email body>
    
  • Handle API errors gracefully — throw a descriptive exception that DispatchLog can record.

HTTP Client: Use Spring's RestClient (Spring Boot 3.2+) — do NOT use WebClient or Feign.

Prompt to use with AI:

"Using the CLAUDE.md context, create AiService in service/. It must call the OpenAI Chat Completions API using Spring's RestClient, using the OPENAI_API_KEY and OPENAI_MODEL env vars. Return the AI's text response. Also implement a parseResponse(raw: String) method that extracts the subject and body from a response formatted as SUBJECT: ...\nBODY:\n...."

Done when:

  • AiService.kt exists in service/.
  • Calling the service with a real API key returns a valid AI-generated email.
  • parseResponse() correctly extracts subject and body.

Step 7 — Email Sender Service (SMTP)

Goal: Send the AI-generated email via SMTP using Spring Mail.

Class: EmailSenderService in service/ package.

Method signature:

fun send(from: String, to: List<String>, subject: String, body: String)
  • from is the VirtualEntity.email (the fictional employee's address).
  • to is the list of all real participants' emails (loaded from config or DB — TBD in Step 9).
  • body may be plain text or simple HTML — send as both text/plain and text/html (multipart).
  • Log every send attempt.

Note on recipients: For now, the list of recipients (the real friends' emails) will be stored as a comma-separated string in application.yml under app.recipients. This can be made dynamic later.

Prompt to use with AI:

"Using the CLAUDE.md context, create EmailSenderService in service/. It must use JavaMailSender to send emails. The from address comes from the entity, the to list comes from app.recipients in config, and the body is sent as both plain text and HTML."

Done when:

  • EmailSenderService.kt exists in service/.
  • Email is received in a real inbox when tested with valid SMTP credentials.

Step 8 — Scheduler (Trigger Per Entity)

Goal: Automatically trigger each active VirtualEntity at its configured schedule.

Class: EntityScheduler in scheduler/ package.

Approach:

  • Spring's @Scheduled with a fixed-rate tick (every 60 seconds) checks which entities are due.
  • Use a ScheduledTaskRegistrar (or dynamic scheduling) to register a task per entity using its schedule_cron expression.
  • When triggered, orchestrate the full pipeline:
    1. Read emails via EmailReaderService (using entity.contextWindowDays).
    2. Build prompt via PromptBuilderService.
    3. Call AI via AiService.
    4. Parse AI response (subject + body).
    5. Send email via EmailSenderService.
    6. Save a DispatchLog record with status SENT or FAILED.
  • If the pipeline fails at any step, save a DispatchLog with status FAILED and the error message.

Prompt to use with AI:

"Using the CLAUDE.md context, create EntityScheduler in scheduler/. It must dynamically schedule a cron job per active VirtualEntity using SchedulingConfigurer. When triggered, it must run the full pipeline: read emails → build prompt → call AI → send email → save DispatchLog. Handle failures gracefully and always write a DispatchLog."

Done when:

  • EntityScheduler.kt exists in scheduler/.
  • @EnableScheduling is present on the main app or a config class.
  • An active entity triggers at its scheduled time and a DispatchLog record is created.
  • End-to-end test: entity fires → email arrives in inbox.

Step 9 — REST Controllers & DTOs

Goal: Expose CRUD operations for VirtualEntity and read-only access to DispatchLog.

Controllers:

VirtualEntityController/api/v1/virtual-entities

Method Path Description
POST / Create a new virtual entity
GET / List all virtual entities
GET /{id} Get one entity by ID
PUT /{id} Update an entity
DELETE /{id} Deactivate an entity (soft delete)
POST /{id}/trigger Manually trigger the entity pipeline

DispatchLogController/api/v1/dispatch-logs

Method Path Description
GET / List all dispatch logs
GET /entity/{id} List logs for a specific entity

DTOs to create (in dto/ package):

  • VirtualEntityCreateDto — name, email, jobTitle, personality, scheduleCron, contextWindowDays
  • VirtualEntityUpdateDto — same fields, all optional
  • VirtualEntityResponseDto — full entity fields + id + createdAt
  • DispatchLogResponseDto — all DispatchLog fields

Prompt to use with AI:

"Using the CLAUDE.md context, create VirtualEntityController and DispatchLogController in controller/, and all DTOs in dto/. Controllers return ResponseEntity<T>. The /trigger endpoint manually runs the entity pipeline. DTOs use validation annotations."

Done when:

  • All controller and DTO files exist.
  • Swagger UI shows all endpoints.
  • CRUD via Swagger UI or Postman works end-to-end.

Step 10 — Authentication (JWT Login)

Goal: Implement the single-admin JWT login that protects all API endpoints.

Approach:

  • POST /api/auth/login accepts { "password": "..." }, validates against APP_PASSWORD env var.
  • On success, generates a JWT (signed with JWT_SECRET, expiry from JWT_EXPIRATION_MS) and sets it as an httpOnly cookie in the response.
  • Spring Security JwtAuthFilter (extends OncePerRequestFilter) validates the cookie on every protected request.
  • Public paths: POST /api/auth/login, /swagger-ui.html, /v3/api-docs/**.
  • There is no user table — the password lives only in the environment variable.

Classes to create:

  • AuthControllerPOST /api/auth/login endpoint
  • AuthService — validates password, generates JWT
  • JwtService — signs and validates JWT tokens
  • JwtAuthFilter — reads cookie, validates JWT, sets SecurityContext
  • SecurityConfig — Spring Security HTTP config (permit login + swagger, protect everything else)

DTOs:

  • LoginRequest{ "password": String }
  • AuthResponse{ "message": String } (cookie is set on the response; no token in body)

Prompt to use with AI:

"Using the CLAUDE.md context, implement JWT authentication for the single-admin model. Create AuthController, AuthService, JwtService, JwtAuthFilter, and SecurityConfig. POST /api/auth/login validates against APP_PASSWORD env var and returns a JWT in an httpOnly cookie. All other endpoints require the JWT cookie. Swagger UI is public."

Done when:

  • POST /api/auth/login with correct password sets an httpOnly JWT cookie and returns 200.
  • POST /api/auth/login with wrong password returns 401.
  • All /api/v1/** endpoints return 401 without a valid JWT cookie.
  • Swagger UI remains accessible without auth.
  • Password and JWT secret are never hardcoded.

Step 11 — React Frontend

Goal: Build the admin SPA that communicates with the backend over the JWT cookie session.

Pages to create:

Page Path Description
LoginPage /login Password input form → calls POST /api/auth/login
DashboardPage / Overview: entity count, recent dispatch log summary
EntitiesPage /entities List, create, edit, delete, toggle active virtual entities
LogsPage /logs Paginated dispatch logs with status badges and full details

Structure under frontend/src/:

api/
  authApi.ts          — login, logout calls
  entitiesApi.ts      — CRUD for VirtualEntity
  logsApi.ts          — fetch DispatchLog records
components/
  EntityCard.tsx      — card for a single entity
  LogRow.tsx          — row for a dispatch log entry
  ProtectedRoute.tsx  — redirects to /login if no valid session
  NavBar.tsx          — top navigation bar
pages/
  LoginPage.tsx
  DashboardPage.tsx
  EntitiesPage.tsx
  LogsPage.tsx
router/
  index.tsx           — React Router config with lazy-loaded routes

Key rules:

  • All server state via React Query — no useState for API data.
  • All API calls go through src/api/ — never call axios directly in a component.
  • Use shadcn/ui for all UI components (Button, Input, Table, Badge, Dialog, etc.).
  • ProtectedRoute checks for a live backend session by calling GET /api/auth/me (add this endpoint to AuthController).
  • Login form submits to POST /api/auth/login — on success React Query invalidates and React Router navigates to /.

Prompt to use with AI:

"Using the CLAUDE.md context, build the React frontend. Create all four pages (Login, Dashboard, Entities, Logs) with React Query for data fetching, shadcn/ui for components, and React Router for navigation. Implement ProtectedRoute using GET /api/auth/me. All API calls must go through the src/api/ layer."

Done when:

  • npm run build succeeds with no TypeScript errors.
  • npm run dev serves the app and login flow works end-to-end.
  • Unauthenticated users are redirected to /login.
  • Entities can be created, edited, toggled active, and deleted via the UI.
  • Dispatch logs are visible and filterable by entity.
  • All pages have at least a smoke test (npm run test passes).

Step 12 — Unit & Integration Tests

Goal: Test every service class and one integration test per controller.

Backend tests

Test Class Type Covers
EmailReaderServiceTest Unit IMAP fetch, empty list on error
PromptBuilderServiceTest Unit Prompt matches template, fields substituted
AiServiceTest Unit API call mocked, parseResponse parsing
EmailSenderServiceTest Unit JavaMailSender called with correct args
AuthServiceTest Unit Correct/incorrect password, JWT generation
VirtualEntityControllerTest Integration CRUD endpoints, trigger endpoint
DispatchLogControllerTest Integration List all, list by entity

Frontend tests

Test File Covers
LoginPage.test.tsx Renders, submits form, shows error on wrong password
EntitiesPage.test.tsx Lists entities, opens create dialog, handles delete
authApi.test.ts login() calls correct endpoint with correct payload
entitiesApi.test.ts CRUD functions call correct endpoints

Prompt to use with AI:

"Using the CLAUDE.md context, generate unit tests for all backend service classes using MockK, integration tests for controllers using @SpringBootTest with H2, and frontend component and API layer tests using Vitest + React Testing Library. Follow naming convention should_[expectedBehavior]_when_[condition] for backend tests."

Done when:

  • ./gradlew test passes all backend tests green.
  • Backend service class coverage ≥ 80%.
  • npm run test passes all frontend tests green.

Step 13 — Docker Compose (Dev + Prod)

Goal: Containerize both services and wire them together for local dev and production.

Files to create / update:

condado-news-letter/
├── backend/Dockerfile          # Multi-stage: Gradle build → slim JRE runtime
├── frontend/Dockerfile         # Multi-stage: Node build → Nginx static file server
├── nginx/nginx.conf            # Serve SPA + proxy /api to backend
├── docker-compose.yml          # Dev: Nginx + Backend + PostgreSQL + Mailhog
└── docker-compose.prod.yml     # Prod: Nginx + Backend + PostgreSQL (no Mailhog)

Notes:

  • Use Mailhog in dev (SMTP port 1025, web UI port 8025).
  • The nginx service serves the built React SPA and proxies /api/** to backend:8080.
  • Backend and Postgres communicate over an internal Docker network.
  • Env vars come from .env at the repo root (copied from .env.example).

Prompt to use with AI:

"Using the CLAUDE.md context, create multi-stage Dockerfiles for the backend and frontend, an nginx/nginx.conf that serves the React SPA and proxies /api to the backend, a docker-compose.yml for dev (includes Mailhog), and a docker-compose.prod.yml for production. Use .env at the repo root for all env vars."

Done when:

  • docker compose up --build starts all services without errors.
  • http://localhost serves the React SPA.
  • http://localhost/api/v1/virtual-entities is proxied to the backend.
  • Outgoing emails appear in Mailhog at http://localhost:8025.
  • docker compose -f docker-compose.prod.yml up --build works (no Mailhog).

Step 14 — All-in-one Docker Image

Goal: Build a single Docker image that runs the entire stack (Nginx + Spring Boot + PostgreSQL) under Supervisor, deployable with a single docker run command.

File to create: Dockerfile.allinone at the repo root.

What the image bundles:

  • Nginx — serves the React SPA and proxies /api to Spring Boot
  • Spring Boot — the backend (from the multi-stage backend build)
  • PostgreSQL — embedded database
  • Supervisor — starts and supervises all three processes

Base approach:

  1. Stage 1: Build frontend (node:20-alpinenpm run build)
  2. Stage 2: Build backend (gradle:8-jdk21-alpine./gradlew bootJar)
  3. Stage 3: Final image (ubuntu:24.04 or debian:bookworm-slim)
    • Install: nginx, postgresql, supervisor, openjdk-21-jre-headless
    • Copy frontend build → /usr/share/nginx/html/
    • Copy backend JAR → /app/app.jar
    • Copy nginx/nginx.conf/etc/nginx/nginx.conf
    • Add a supervisord.conf that starts all three processes
    • Add an entrypoint.sh that initialises the PostgreSQL data directory on first run and sets SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/condado

Supervisor config (supervisord.conf):

[supervisord]
nodaemon=true

[program:postgres]
command=/usr/lib/postgresql/16/bin/postgres -D /var/lib/postgresql/data
user=postgres
autostart=true
autorestart=true

[program:backend]
command=java -jar /app/app.jar
autostart=true
autorestart=true
startsecs=10

[program:nginx]
command=/usr/sbin/nginx -g "daemon off;"
autostart=true
autorestart=true

Minimal run command (from CLAUDE.md):

docker run -d \
  -p 80:80 \
  -e APP_PASSWORD=yourpassword \
  -e JWT_SECRET=yoursecret \
  -e OPENAI_API_KEY=sk-... \
  -e MAIL_HOST=smtp.example.com \
  -e MAIL_PORT=587 \
  -e MAIL_USERNAME=company@example.com \
  -e MAIL_PASSWORD=secret \
  -e IMAP_HOST=imap.example.com \
  -e IMAP_PORT=993 \
  -e APP_RECIPIENTS=friend1@example.com,friend2@example.com \
  -v condado-data:/var/lib/postgresql/data \
  <dockerhub-user>/condado-newsletter:latest

Prompt to use with AI:

"Using the CLAUDE.md context, create Dockerfile.allinone at the repo root. It must be a multi-stage build: stage 1 builds the frontend, stage 2 builds the backend, stage 3 assembles everything into a single Ubuntu/Debian image with Nginx, PostgreSQL, Spring Boot, and Supervisor. Include an entrypoint.sh that initialises the PostgreSQL data dir on first run."

Done when:

  • docker build -f Dockerfile.allinone -t condado-newsletter . succeeds.
  • docker run -p 80:80 -e APP_PASSWORD=test -e JWT_SECRET=testsecret ... condado-newsletter serves the app at http://localhost.
  • Data persists across container restarts when a volume is mounted.
  • All three processes (nginx, java, postgres) are visible in docker exec ... supervisorctl status.

Step 15 — CI/CD (GitHub Actions + Docker Hub)

Goal: Automate testing on every PR and publish the all-in-one image to Docker Hub on every merge to main.

Files to create:

.github/
└── workflows/
    ├── ci.yml       — run backend + frontend tests on every push / PR
    └── publish.yml  — build Dockerfile.allinone and push to Docker Hub on push to main

ci.yml — Continuous Integration

Triggers: push and pull_request on any branch.

Jobs:

  1. backend-test
    • actions/checkout
    • actions/setup-java (JDK 21)
    • ./gradlew test in backend/
    • Upload test results as artifact
  2. frontend-test
    • actions/checkout
    • actions/setup-node (Node 20)
    • npm ci then npm run test in frontend/

publish.yml — Docker Hub Publish

Triggers: push to main only.

Steps:

  1. actions/checkout
  2. docker/setup-buildx-action
  3. docker/login-action — uses DOCKERHUB_USERNAME + DOCKERHUB_TOKEN secrets
  4. docker/build-push-action
    • File: Dockerfile.allinone
    • Tags:
      • ${{ secrets.DOCKERHUB_USERNAME }}/condado-newsletter:latest
      • ${{ secrets.DOCKERHUB_USERNAME }}/condado-newsletter:${{ github.sha }}
    • push: true

Required GitHub repository secrets:

Secret Where to set Value
DOCKERHUB_USERNAME Repo → Settings → Secrets Your Docker Hub username
DOCKERHUB_TOKEN Repo → Settings → Secrets Docker Hub access token (not password)

Prompt to use with AI:

"Using the CLAUDE.md context, create .github/workflows/ci.yml that runs backend Gradle tests and frontend Vitest tests on every push/PR. Also create .github/workflows/publish.yml that builds Dockerfile.allinone and pushes two tags (latest + git SHA) to Docker Hub using DOCKERHUB_USERNAME and DOCKERHUB_TOKEN secrets, triggered only on push to main."

Done when:

  • Every PR shows green CI checks for both backend and frontend tests.
  • Merging to main triggers an image build and push to Docker Hub.
  • Both latest and <git-sha> tags are visible on Docker Hub after a push.
  • Workflow files pass YAML linting (actionlint or similar).

Notes & Decisions Log

Date Decision Reason
2026-03-26 Chose Kotlin + Spring Boot 3.x Modern, type-safe, great Spring support
2026-03-26 MockK over Mockito More idiomatic for Kotlin
2026-03-26 UUID as primary keys Better for distributed systems
2026-03-26 No Thymeleaf — AI generates email content directly Email body is AI-produced, no template needed
2026-03-26 JWT auth (single admin, password via env var) No user table needed; simple and secure for a private tool
2026-03-26 Use Spring RestClient for OpenAI (not WebClient) Spring Boot 3.2+ preferred HTTP client
2026-03-26 Recipients stored in app.recipients config Simple starting point, can be made dynamic later
2026-03-26 PromptBuilderService is the only prompt builder Keeps prompt logic centralized and testable
2026-03-26 AI must format response as SUBJECT: ...\nBODY:\n... Allows reliable parsing of subject vs body
2026-03-26 React + Vite + shadcn/ui for frontend Modern, fast DX; Tailwind + Radix keeps UI consistent
2026-03-26 All-in-one Docker image (Supervisor + Nginx + PG + JVM) Simplest possible single-command deployment for friends
2026-03-26 GitHub Actions CI/CD → Docker Hub publish on main Automated image publishing; pinnable via git SHA tags