- 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.
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:
- Has a name, email address, job title, and a personality description.
- Has a scheduled time to send emails (e.g., every Monday at 9am).
- 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.
- 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.mdfor context. - After completing each step, mark it ✅ Done and note any decisions made.
- If anything changes (new library, schema change, etc.), update
CLAUDE.mdtoo.
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.mdwith 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-4oas 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.mdand 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, placeholderdocker-compose.yml,docker-compose.prod.yml,Dockerfile.allinone,nginx/nginx.conf, and GitHub Actions workflow stubs at.github/workflows/ci.ymland.github/workflows/publish.yml. Do not implement business logic yet — just the skeleton."
Done when:
cd backend && ./gradlew buildcompiles with no errors.cd frontend && npm install && npm run buildsucceeds.- Application starts with
./gradlew bootRun(backend) without errors. npm run devstarts the Vite dev server.docker compose up --buildstarts 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:
VirtualEntityandDispatchLog. Place them in themodel/package. Use UUIDs as primary keys and proper JPA annotations.DispatchLoghas a@ManyToOnerelationship toVirtualEntity."
Done when:
- Both entity files exist in
src/main/kotlin/com/condado/newsletter/model/. ./gradlew buildcompiles cleanly.- Tables are auto-created by Hibernate on startup (
ddl-auto: create-dropin 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 extendJpaRepositoryand include the custom query methods listed in the instructions."
Done when:
- Both repository files exist in
src/main/kotlin/com/condado/newsletter/repository/. ./gradlew buildcompiles 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
Ndays. - Return a list of
EmailContextdata objects, each containing:from: String— sender name/addresssubject: String— email subjectbody: 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
EmailReaderServiceclass inservice/. 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 ofEmailContextdata objects sorted chronologically. Use Jakarta Mail."
Done when:
EmailReaderService.ktexists inservice/.EmailContext.ktdata 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
PromptBuilderServiceinservice/. It must implementbuildPrompt(entity, emailContext)following the prompt template defined in CLAUDE.md exactly. This is the only class allowed to build prompt strings."
Done when:
PromptBuilderService.ktexists inservice/.- Output prompt matches the template from
CLAUDE.mdwith 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_MODELenv var (default:gpt-4o). - Send the prompt as a
usermessage. - Return the AI's response as a plain
String. - Parse the response to extract
subjectandbody— 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
DispatchLogcan 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
AiServiceinservice/. It must call the OpenAI Chat Completions API using Spring'sRestClient, using theOPENAI_API_KEYandOPENAI_MODELenv vars. Return the AI's text response. Also implement aparseResponse(raw: String)method that extracts the subject and body from a response formatted asSUBJECT: ...\nBODY:\n...."
Done when:
AiService.ktexists inservice/.- 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)
fromis theVirtualEntity.email(the fictional employee's address).tois the list of all real participants' emails (loaded from config or DB — TBD in Step 9).bodymay be plain text or simple HTML — send as bothtext/plainandtext/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
EmailSenderServiceinservice/. It must useJavaMailSenderto send emails. Thefromaddress comes from the entity, thetolist comes fromapp.recipientsin config, and the body is sent as both plain text and HTML."
Done when:
EmailSenderService.ktexists inservice/.- 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
@Scheduledwith 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 itsschedule_cronexpression. - When triggered, orchestrate the full pipeline:
- Read emails via
EmailReaderService(usingentity.contextWindowDays). - Build prompt via
PromptBuilderService. - Call AI via
AiService. - Parse AI response (subject + body).
- Send email via
EmailSenderService. - Save a
DispatchLogrecord with status SENT or FAILED.
- Read emails via
- If the pipeline fails at any step, save a
DispatchLogwith status FAILED and the error message.
Prompt to use with AI:
"Using the CLAUDE.md context, create
EntitySchedulerinscheduler/. It must dynamically schedule a cron job per activeVirtualEntityusingSchedulingConfigurer. When triggered, it must run the full pipeline: read emails → build prompt → call AI → send email → saveDispatchLog. Handle failures gracefully and always write aDispatchLog."
Done when:
EntityScheduler.ktexists inscheduler/.@EnableSchedulingis present on the main app or a config class.- An active entity triggers at its scheduled time and a
DispatchLogrecord 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, contextWindowDaysVirtualEntityUpdateDto— same fields, all optionalVirtualEntityResponseDto— full entity fields + id + createdAtDispatchLogResponseDto— all DispatchLog fields
Prompt to use with AI:
"Using the CLAUDE.md context, create
VirtualEntityControllerandDispatchLogControllerincontroller/, and all DTOs indto/. Controllers returnResponseEntity<T>. The/triggerendpoint 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/loginaccepts{ "password": "..." }, validates againstAPP_PASSWORDenv var.- On success, generates a JWT (signed with
JWT_SECRET, expiry fromJWT_EXPIRATION_MS) and sets it as anhttpOnlycookie in the response. - Spring Security
JwtAuthFilter(extendsOncePerRequestFilter) 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:
AuthController—POST /api/auth/loginendpointAuthService— validates password, generates JWTJwtService— signs and validates JWT tokensJwtAuthFilter— reads cookie, validates JWT, setsSecurityContextSecurityConfig— 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, andSecurityConfig.POST /api/auth/loginvalidates againstAPP_PASSWORDenv 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/loginwith correct password sets anhttpOnlyJWT cookie and returns200.POST /api/auth/loginwith wrong password returns401.- All
/api/v1/**endpoints return401without 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
useStatefor API data. - All API calls go through
src/api/— never callaxiosdirectly in a component. - Use shadcn/ui for all UI components (Button, Input, Table, Badge, Dialog, etc.).
ProtectedRoutechecks for a live backend session by callingGET /api/auth/me(add this endpoint toAuthController).- 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
ProtectedRouteusingGET /api/auth/me. All API calls must go through thesrc/api/layer."
Done when:
npm run buildsucceeds with no TypeScript errors.npm run devserves 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 testpasses).
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
@SpringBootTestwith H2, and frontend component and API layer tests using Vitest + React Testing Library. Follow naming conventionshould_[expectedBehavior]_when_[condition]for backend tests."
Done when:
./gradlew testpasses all backend tests green.- Backend service class coverage ≥ 80%.
npm run testpasses 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
nginxservice serves the built React SPA and proxies/api/**tobackend:8080. - Backend and Postgres communicate over an internal Docker network.
- Env vars come from
.envat 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.confthat serves the React SPA and proxies/apito the backend, adocker-compose.ymlfor dev (includes Mailhog), and adocker-compose.prod.ymlfor production. Use.envat the repo root for all env vars."
Done when:
docker compose up --buildstarts all services without errors.http://localhostserves the React SPA.http://localhost/api/v1/virtual-entitiesis proxied to the backend.- Outgoing emails appear in Mailhog at
http://localhost:8025. docker compose -f docker-compose.prod.yml up --buildworks (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
/apito Spring Boot - Spring Boot — the backend (from the multi-stage backend build)
- PostgreSQL — embedded database
- Supervisor — starts and supervises all three processes
Base approach:
- Stage 1: Build frontend (
node:20-alpine→npm run build) - Stage 2: Build backend (
gradle:8-jdk21-alpine→./gradlew bootJar) - Stage 3: Final image (
ubuntu:24.04ordebian: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.confthat starts all three processes - Add an
entrypoint.shthat initialises the PostgreSQL data directory on first run and setsSPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/condado
- Install:
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.allinoneat 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 anentrypoint.shthat 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-newsletterserves the app athttp://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:
backend-testactions/checkoutactions/setup-java(JDK 21)./gradlew testinbackend/- Upload test results as artifact
frontend-testactions/checkoutactions/setup-node(Node 20)npm cithennpm run testinfrontend/
publish.yml — Docker Hub Publish
Triggers: push to main only.
Steps:
actions/checkoutdocker/setup-buildx-actiondocker/login-action— usesDOCKERHUB_USERNAME+DOCKERHUB_TOKENsecretsdocker/build-push-action- File:
Dockerfile.allinone - Tags:
${{ secrets.DOCKERHUB_USERNAME }}/condado-newsletter:latest${{ secrets.DOCKERHUB_USERNAME }}/condado-newsletter:${{ github.sha }}
push: true
- File:
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.ymlthat runs backend Gradle tests and frontend Vitest tests on every push/PR. Also create.github/workflows/publish.ymlthat buildsDockerfile.allinoneand pushes two tags (latest+ git SHA) to Docker Hub usingDOCKERHUB_USERNAMEandDOCKERHUB_TOKENsecrets, triggered only on push tomain."
Done when:
- Every PR shows green CI checks for both backend and frontend tests.
- Merging to
maintriggers an image build and push to Docker Hub. - Both
latestand<git-sha>tags are visible on Docker Hub after a push. - Workflow files pass YAML linting (
actionlintor 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 |