Reviewed-on: #1 Co-authored-by: Gabriel Sancho <gabriel.sancho13@gmail.com> Co-committed-by: Gabriel Sancho <gabriel.sancho13@gmail.com> Reviewed-on: #2
30 KiB
Condado Abaixo da Média SA — Email Bot
A monorepo containing a Kotlin/Spring Boot backend and a React frontend. This file gives the AI persistent instructions and context about the project so every session starts with the right knowledge.
Default Workflow: Test-Driven Development (TDD)
Every implementation step in this project follows TDD. This is non-negotiable.
The cycle for every step is:
| Phase | Action | Gate |
|---|---|---|
| Red | Write test file(s) for the step. Run the test suite. | New tests must fail. |
| Green | Write the minimum implementation to make all tests pass. | All tests must pass. |
| Refactor | Clean up the implementation. | Tests must stay green. |
| Done | Mark step ✅ only when the full build is green. | ./gradlew build / npm run build && npm run test |
Rules:
- Never write implementation code before the test file exists and the tests fail.
- Never mark a step Done unless the full test suite passes.
- Test method names (Kotlin/JUnit):
should_[expectedBehavior]_when_[condition]. - Backend mocking: MockK only (not Mockito).
- Backend integration tests:
@SpringBootTestwith H2 in-memory database. - Frontend tests: Vitest + React Testing Library, mocked Axios.
Project Overview
- Type: Monorepo (backend + frontend in the same repository)
- Purpose: Simulate virtual employees of the fictional company "Condado Abaixo da Média SA". Each entity is an AI-powered fictional employee with a name, email address, job title, personality, and an email schedule. At the scheduled time, the system reads recent emails from the company mailbox (IMAP), builds a prompt, calls the OpenAI API, and dispatches the AI-generated email via SMTP.
- Tone rule (critical): Every generated email must be written in an extremely formal, corporate tone — but the content is completely casual and nonsensical, like internal jokes between friends. This contrast is the core joke of the project and must be preserved in every generated email.
- Architecture: React SPA → Spring Boot REST API → PostgreSQL + IMAP + SMTP + OpenAI
- Deployment: Fully containerized with Docker and Docker Compose
Tech Stack
Backend
| Layer | Technology |
|---|---|
| Language | Kotlin (JVM) |
| Framework | Spring Boot 3.x |
| Build Tool | Gradle (Kotlin DSL — build.gradle.kts) |
| Database | PostgreSQL (via Spring Data JPA) |
| Email Reading | Jakarta Mail (IMAP) — read inbox for context |
| Email Sending | Spring Mail (SMTP / JavaMailSender) |
| AI Integration | OpenAI API (gpt-4o) via Spring RestClient |
| Scheduler | Spring @Scheduled + SchedulingConfigurer |
| Auth | JWT — issued by backend on login |
| Testing | JUnit 5 + MockK |
| Docs | Springdoc OpenAPI (Swagger UI) |
Frontend
| Layer | Technology |
|---|---|
| Language | TypeScript |
| Framework | React 18 + Vite |
| UI Library | shadcn/ui (Radix UI + Tailwind CSS) |
| State | React Query (TanStack Query v5) |
| Routing | React Router v6 |
| HTTP Client | Axios |
| Auth | JWT stored in httpOnly cookie (set by backend) |
| Testing | Vitest + React Testing Library |
Infrastructure
| Layer | Technology |
|---|---|
| Containers | Docker |
| Orchestration | Docker Compose — three flavours (see below) |
| Reverse Proxy | Nginx (serves frontend + proxies /api to backend) |
| Dev Mail | Mailhog (SMTP trap + web UI) |
| All-in-one image | Single Docker image: Nginx + Spring Boot + PostgreSQL + Supervisor |
| Image registry | Gitea container registry (gitea.lab/sancho41/condado-newsletter) |
| CI/CD | Gitea Actions — test PRs to develop, build and publish the production image on approved PRs targeting main |
Deployment Flavours
There are three ways to run the project:
| Flavour | Command | When to use |
|---|---|---|
| Dev | docker compose up |
Local development — includes Mailhog |
| Prod (compose) | docker compose -f docker-compose.prod.yml up -d |
Production with the prebuilt all-in-one image |
| All-in-one | docker run ... |
Simplest deploy — everything in one container |
All-in-one Image
The all-in-one image (Dockerfile.allinone) bundles everything into a single container:
- Nginx — serves the React SPA and proxies
/apito Spring Boot - Spring Boot — the backend API + scheduler
- PostgreSQL — embedded database
- Supervisor — process manager that starts and supervises all three processes
The all-in-one image is built on the runner host and then published to the Gitea container registry.
Minimal docker run command:
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 \
gitea.lab/sancho41/condado-newsletter:latest
The app is then available at http://localhost.
System Topology
Multi-container (Docker Compose)
┌─────────────────────────────────────────────────────────────────┐
│ Docker Compose │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ nginx :80 / :443 │ │
│ │ • Serves React SPA (static files) │ │
│ │ • Proxies /api/** ──────────────────────────────────► │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ backend :8080 │ │
│ │ Spring Boot (Kotlin) │ │
│ │ • REST API /api/v1/** │ │
│ │ • JWT auth /api/auth/login │ │
│ │ • Swagger /swagger-ui.html │ │
│ │ • Scheduler (cron per VirtualEntity) │ │
│ └──────────┬───────────────┬──────────────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌────────────────────────────────────────┐ │
│ │ postgres :5432│ │ External services (outside Docker) │ │
│ │ PostgreSQL │ │ • OpenAI API (HTTPS) │ │
│ │ DB: condado │ │ • SMTP server (send emails) │ │
│ └──────────────┘ │ • IMAP server (read inbox) │ │
│ └────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ mailhog :1025 (SMTP) / :8025 (UI) │ │
│ │ DEV ONLY — catches outgoing emails │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Browser ──────► nginx :80
├── / → React SPA (index.html)
├── /assets → Static JS/CSS
└── /api/** → backend :8080
All-in-one Image (single docker run)
┌──────────────────────────────────────────────────────────────┐
│ condado-newsletter :80 (single container) │
│ │
│ supervisord │
│ ├── nginx (port 80 — SPA + /api proxy) │
│ ├── java -jar app (Spring Boot :8080 — internal only) │
│ └── postgres (PostgreSQL :5432 — internal only) │
│ │
│ Persistent data → Docker volume mounted at │
│ /var/lib/postgresql/data │
│ │
│ External (via env vars): │
│ • OpenAI API (HTTPS) │
│ • SMTP server (send emails) │
│ • IMAP server (read inbox) │
└──────────────────────────────────────────────────────────────┘
Data flows:
- User opens browser → Nginx serves the React app.
- React calls
POST /api/auth/loginwith password → backend validates againstAPP_PASSWORDenv var → returns JWT inhttpOnlycookie. - React calls
/api/v1/virtual-entities(JWT cookie sent automatically) → backend responds. - Scheduler ticks → for each due
VirtualEntity:- Backend reads IMAP inbox (external IMAP server).
- Builds prompt → calls OpenAI API.
- Sends generated email via SMTP (Mailhog in dev, real SMTP in prod).
- Saves
DispatchLogto PostgreSQL.
Monorepo Structure
condado-news-letter/ ← repo root
├── CLAUDE.md
├── INSTRUCTIONS.md
├── .env.example ← template for all env vars
├── .gitignore
├── docker-compose.yml ← dev stack (Nginx + Backend + PostgreSQL + Mailhog)
├── docker-compose.prod.yml ← prod stack (single all-in-one image)
├── Dockerfile.allinone ← all-in-one image (Nginx + Backend + PostgreSQL + Supervisor)
│
├── .github/
│ └── workflows/
│ └── (legacy, unused after Gitea migration)
├── .gitea/
│ └── workflows/
│ └── ci.yml ← run tests on pull requests targeting `develop`
│
├── backend/ ← Spring Boot (Kotlin + Gradle)
│ ├── build.gradle.kts
│ ├── settings.gradle.kts
│ ├── Dockerfile
│ └── src/
│ ├── main/kotlin/com/condado/newsletter/
│ │ ├── CondadoApplication.kt
│ │ ├── config/
│ │ ├── controller/
│ │ ├── service/
│ │ │ ├── AuthService.kt
│ │ │ ├── EntityService.kt
│ │ │ ├── EmailReaderService.kt
│ │ │ ├── PromptBuilderService.kt
│ │ │ ├── AiService.kt
│ │ │ └── EmailSenderService.kt
│ │ ├── repository/
│ │ ├── model/
│ │ ├── dto/
│ │ └── scheduler/
│ └── main/resources/
│ ├── application.yml
│ └── application-dev.yml
│
├── frontend/ ← React (TypeScript + Vite)
│ ├── package.json
│ ├── vite.config.ts
│ ├── tsconfig.json
│ ├── Dockerfile
│ └── src/
│ ├── main.tsx
│ ├── App.tsx
│ ├── api/ ← Axios client + React Query hooks
│ ├── components/ ← Reusable UI components (shadcn/ui)
│ ├── pages/
│ │ ├── LoginPage.tsx
│ │ ├── DashboardPage.tsx
│ │ ├── EntitiesPage.tsx
│ │ └── LogsPage.tsx
│ └── router/ ← React Router config
│
└── nginx/
└── nginx.conf ← Shared Nginx config (used in both Docker flavours)
Build & Run Commands
Backend
cd backend
# Build the project
./gradlew build
# Run (dev profile)
./gradlew bootRun --args='--spring.profiles.active=dev'
# Run tests
./gradlew test
# Run a specific test
./gradlew test --tests "com.condado.newsletter.service.PromptBuilderServiceTest"
Frontend
cd frontend
# Install dependencies
npm install
# Dev server (with Vite proxy to backend)
npm run dev
# Build for production
npm run build
# Run tests
npm run test
Full Stack (Docker Compose)
# Dev (Mailhog included)
docker compose up --build
# Prod
docker compose -f docker-compose.prod.yml up -d
# Stop
docker compose down
Coding Standards
Project Language Policy
- Primary language is English for code, comments, commit messages, PR titles/descriptions, and user-facing UI text.
- Portuguese may be used only for informal chat outside code artifacts.
- When translating requirements to implementation, preserve meaning but keep identifiers and UI labels in English.
Backend (Kotlin)
- Use Kotlin idiomatic style: data classes, extension functions, and null-safety operators.
- Prefer
valovervarwherever possible. - Use constructor injection for dependencies (never field injection with
@Autowired). - All DTOs must be data classes with validation annotations (
jakarta.validation). - Controller methods must return
ResponseEntity<T>with explicit HTTP status codes. - Services must be annotated with
@Serviceand never depend on controllers. - Repositories must extend
JpaRepository<Entity, IdType>. - Use
@Transactionalon service methods that modify data. - All public functions must have KDoc comments.
- Use
snake_casefor database columns andcamelCasefor Kotlin properties. - Keep controllers thin — business logic belongs in services.
- The AI prompt construction logic must live exclusively in
PromptBuilderService.
Frontend (TypeScript / React)
- All components must be functional — no class components.
- Use TypeScript strict mode — no
anytypes. - All API calls go through the
src/api/layer — never callaxiosdirectly in a component. - Use React Query for all server state — never store server data in
useState. - Use shadcn/ui components for UI — do not build custom UI primitives from scratch.
- All pages live in
src/pages/and are lazy-loaded via React Router. - Protected routes must check for a valid JWT before rendering.
- No hardcoded strings that face the user — use constants or i18n keys.
Authentication Model
- There is one single user — the admin (you and your friends sharing the app).
- The password is set via the
APP_PASSWORDenvironment variable on the backend. POST /api/auth/loginaccepts{ "password": "..." }and returns a JWT in anhttpOnlycookie (no username needed).- The JWT is validated by Spring Security on every protected backend request.
- The React frontend redirects to
/loginif the cookie is absent or the JWT is expired. - There is no user registration, no user table, no role system.
Data Ownership Policy (Critical)
- All business data must be persisted server-side (PostgreSQL via backend APIs).
- The frontend must treat the backend as the single source of truth for entities, tasks, generated preview messages/history, logs, and any other domain data.
- The frontend must not persist business/domain data in browser storage (
localStorage,sessionStorage,IndexedDB) or call LLM providers directly. - The only browser-stored auth state is the backend-issued session token cookie (
httpOnlyJWT). - If a required endpoint does not exist yet, implement it in the backend first; do not add frontend-side persistence workarounds.
Naming Conventions
Backend
| Artifact | Convention | Example |
|---|---|---|
| Classes | PascalCase | PromptBuilderService |
| Functions | camelCase | buildPrompt() |
| Variables | camelCase | entityList |
| Constants | SCREAMING_SNAKE_CASE | MAX_EMAIL_CONTEXT_DAYS |
| DB tables | snake_case (plural) | virtual_entities |
| REST endpoints | kebab-case | /api/v1/virtual-entities |
| Packages | lowercase | com.condado.newsletter |
Frontend
| Artifact | Convention | Example |
|---|---|---|
| Components | PascalCase | EntityCard.tsx |
| Hooks | camelCase + use |
useEntities.ts |
| API files | camelCase | entitiesApi.ts |
| Pages | PascalCase + Page |
EntitiesPage.tsx |
| CSS classes | kebab-case (Tailwind) | text-sm font-medium |
Testing Guidelines
This project follows strict TDD. For every implementation step, tests are written first (Red), then the implementation is added until all tests pass (Green), then code is cleaned up (Refactor). Never write implementation code before the tests exist and fail.
TDD Workflow (apply to every step)
- Red — Write the test file(s). Run
./gradlew test(backend) ornpm run test(frontend) and confirm the new tests fail (compile errors are acceptable at this stage). - Green — Write the minimum implementation needed to make all tests pass.
- Refactor — Clean up the implementation while keeping tests green.
- A step is only ✅ Done when
./gradlew build(backend) ornpm run build && npm run test(frontend) is fully green.
Backend
- Every service class must have a corresponding unit test class written before the service.
- Use MockK for mocking (not Mockito).
- Integration tests use
@SpringBootTestand an H2 in-memory database. - Test method names follow:
should_[expectedBehavior]_when_[condition]. - Minimum 80% code coverage for service classes.
- Test files live in
src/test/kotlin/mirroring thesrc/main/kotlin/package structure.
Frontend
- Every page component must have at least a smoke test (renders without crashing), written before the component.
- API layer functions must be tested with mocked Axios responses.
- Use Vitest as the test runner and React Testing Library for component tests.
- Test files live in
src/__tests__/mirroring thesrc/structure.
Environment Variables
All variables are defined in .env (root of the monorepo) and injected by Docker Compose.
Never hardcode any of these values.
| Variable | Used by | Description |
|---|---|---|
APP_PASSWORD |
Backend | Single admin password for login |
JWT_SECRET |
Backend | Secret key for signing/verifying JWTs |
JWT_EXPIRATION_MS |
Backend | JWT expiry in milliseconds (e.g. 86400000 = 1 day) |
SPRING_DATASOURCE_URL |
Backend | PostgreSQL connection URL |
SPRING_DATASOURCE_USERNAME |
Backend | DB username |
SPRING_DATASOURCE_PASSWORD |
Backend | DB password |
MAIL_HOST |
Backend | SMTP host (Mailhog in dev, real SMTP in prod) |
MAIL_PORT |
Backend | SMTP port |
MAIL_USERNAME |
Backend | SMTP username (also IMAP login) |
MAIL_PASSWORD |
Backend | SMTP/IMAP password |
IMAP_HOST |
Backend | IMAP host for reading the shared inbox |
IMAP_PORT |
Backend | IMAP port (default: 993) |
IMAP_INBOX_FOLDER |
Backend | IMAP folder to read (default: INBOX) |
OPENAI_API_KEY |
Backend | OpenAI API key |
OPENAI_MODEL |
Backend | OpenAI model (default: gpt-4o) |
APP_RECIPIENTS |
Backend | Comma-separated list of recipient emails |
VITE_API_BASE_URL |
Frontend | Backend API base URL for the Vite dev server proxy |
⚠️ Never hardcode credentials. Always use environment variables or a
.envfile (gitignored).
Key Domain Concepts
-
VirtualEntity: A fictional employee of "Condado Abaixo da Média SA". Has a name, a real email address (used as sender), a job title, a personality description, an email schedule (cron expression), and an email context window (how many days back to read emails for context).
-
EmailContext: A snapshot of recent emails read from the shared IMAP inbox, filtered by the entity's configured context window (e.g., last 3 days). Used to give the AI conversational context.
-
Prompt: The full text sent to the OpenAI API. Built by
PromptBuilderServicefrom the entity's profile + theEmailContext. Always instructs the AI to write in an extremely formal corporate tone with completely casual/nonsensical content. -
DispatchLog: A record of each AI email generation and send attempt for a given entity. Stores the generated prompt, the AI response, send status, and timestamp.
The Prompt Template (Core Logic)
Every prompt sent to the AI must follow this structure:
You are [entity.name], [entity.jobTitle] at "Condado Abaixo da Média SA".
Your personality: [entity.personality]
IMPORTANT TONE RULE: You must write in an extremely formal, bureaucratic, corporate tone —
as if writing an official memo. However, the actual content of the email must be completely
casual, trivial, or nonsensical — as if talking to close friends about mundane things.
The contrast between the formal tone and the casual content is intentional and essential.
Here are the most recent emails from the company inbox (last [entity.contextWindowDays] days)
for context:
[list of recent emails: sender, subject, body]
Write a new email to be sent to the company group, continuing the conversation naturally.
Reply or react to the recent emails if relevant. Sign off as [entity.name], [entity.jobTitle].
Format your response exactly as:
SUBJECT: <subject line here>
BODY:
<full email body here>
Task Preview Generation Rules
- The frontend must never call LLM providers (OpenAI/Ollama/Llama) directly.
- The frontend requests backend endpoints only.
- The backend is responsible for:
- building the final prompt,
- calling the configured LLM endpoint,
- returning the generated message to the frontend.
- Generated preview message history must be persisted in the backend database (not browser storage), so history survives reloads and restarts.
Git Workflow & CI/CD
- Git hosting: Gitea instance at
http://gitea.lab. - Canonical remote:
origin = http://gitea.lab/sancho41/condado-newsletter.git. - Branch model: Git Flow (
main+developas permanent branches). - Branch naming:
feature/<short-description>,fix/<short-description>,hotfix/<short-description>,release/<short-description>,chore/<short-description> - Commit messages follow Conventional Commits:
feat:,fix:,chore:,docs:,test: - Scope your commits:
feat(backend):,feat(frontend):,chore(docker): - TDD commit order per step: first
test(<scope>): add failing tests for <step>, thenfeat(<scope>): implement <step> — all tests passing. - Pull requests must target
developfor regular work. - CI runs on pull requests to
developand must pass before merge. - Never commit directly to
mainordevelop.
Commit Rules (enforced by AI)
These rules apply to every commit made during AI-assisted implementation:
| Rule | Detail |
|---|---|
| Two commits per TDD step | 1st commit = failing tests (Red), 2nd commit = passing implementation (Green) |
| Commit after each step | Never accumulate multiple steps in one commit |
| Red commit subject | test(<scope>): add failing tests for step <N> — <short description> |
| Green commit subject | feat(<scope>): implement step <N> — <short description> |
| Scope values | backend, frontend, docker, ci, config |
| Body | Optional but encouraged: list what was added/changed |
No --no-verify |
Never bypass git hooks |
| No force push | Never use --force on shared branches |
| Atomic commits | Each commit must leave the build green (except deliberate Red-phase test commits) |
chore for housekeeping |
Config changes, dependency tweaks, file renames → chore(<scope>): |
fix for bug fixes |
fix(<scope>): <what was broken and how it was fixed> |
docs for documentation |
Changes to CLAUDE.md, INSTRUCTIONS.md, README.md → docs: |
Meaningful Commit Quality Bar
Every commit must be understandable without opening the full diff.
- Subject line must explain intent + scope + outcome.
- Avoid vague subjects like
update frontend,fix stuff,changes,wip. - Keep commits focused on one concern (tests, feature, refactor, docs), not mixed changes.
- Prefer small atomic commits that can be reverted safely.
- Include a commit body when context is not obvious (what changed, why, risks).
Good examples:
test(frontend): add failing tests for step 2 - entity task detail flowfeat(frontend): implement step 2 - per-entity scheduled task creationdocs(config): clarify english-first language policy and commit quality rules
Gitea Actions Workflows
| Workflow file | Trigger | What it does |
|---|---|---|
.gitea/workflows/ci.yml |
PR to develop |
Backend tests (./gradlew test) + Frontend tests (npm run test) |
.gitea/workflows/build.yml |
Approved PR review on main |
Build condado-newsletter, then publish latest and ${github.sha} tags to Gitea container registry |
Build policy: the runner shares the target Docker host, so the build workflow produces the image locally, tags it for gitea.lab/sancho41/condado-newsletter, and pushes it to Gitea container registry. docker-compose.prod.yml references that published image.
Step 1 Decisions & Versions
| Decision | Detail |
|---|---|
| Gradle wrapper | 8.14.1 (upgraded from 8.7 — Gradle < 8.14 cannot parse Java version 26) |
| Spring Boot | 3.4.5 (latest stable at time of scaffold) |
| Kotlin | 2.1.21 (latest stable, bundled with Gradle 8.14.1) |
| Java toolchain | 21 configured in build.gradle.kts via kotlin { jvmToolchain(21) } — bytecode targets Java 21 regardless of host JDK |
| Frontend test script | vitest run --passWithNoTests — prevents CI failure before Step 12 adds real tests |