Add build instructions and project structure for Condado Abaixo da Média SA Email Bot

- Created INSTRUCTIONS.md detailing project goals, usage, and progress tracking.
- Defined project scope, technology stack, and core domain concepts.
- Outlined step-by-step build process from scaffolding to deployment.
- Included detailed descriptions for each step, including entity models, services, and controllers.
- Established a decision log to track key choices made during development.
This commit is contained in:
2026-03-26 14:07:59 -03:00
parent d4b566509a
commit d834ca85b0
2 changed files with 980 additions and 52 deletions

160
CLAUDE.md
View File

@@ -1,6 +1,6 @@
# Condado Newsletter Bot
# Condado Abaixo da Média SA — Email Bot
A newsletter bot built with **Kotlin** and **Spring Boot**. This file gives Claude persistent
A backend service built with **Kotlin** and **Spring Boot**. This file gives the AI persistent
instructions and context about the project so every session starts with the right knowledge.
---
@@ -9,23 +9,33 @@ instructions and context about the project so every session starts with the righ
- **Language:** Kotlin (JVM)
- **Framework:** Spring Boot 3.x
- **Purpose:** Automate the creation, management, and delivery of newsletters
- **Architecture:** REST API backend with scheduled jobs for sending newsletters
- **Purpose:** Simulate virtual employees of the fictional company "Condado Abaixo da Média SA".
Each entity is a virtual 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
(filtered by a configurable time window), builds a prompt from the entity's profile + the email
history, sends that prompt to an AI (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:** REST API backend + scheduled AI-driven email dispatch
---
## Tech Stack
| Layer | Technology |
|---------------|-----------------------------------|
| Language | Kotlin |
| Framework | Spring Boot 3.x |
| Build Tool | Gradle (Kotlin DSL - `build.gradle.kts`) |
| Database | PostgreSQL (via Spring Data JPA) |
| Email | Spring Mail (SMTP / JavaMailSender) |
| Scheduler | Spring `@Scheduled` tasks |
| Testing | JUnit 5 + MockK |
| Docs | Springdoc OpenAPI (Swagger UI) |
| 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 HTTP client |
| Scheduler | Spring `@Scheduled` tasks |
| Testing | JUnit 5 + MockK |
| Docs | Springdoc OpenAPI (Swagger UI) |
---
@@ -35,20 +45,24 @@ instructions and context about the project so every session starts with the righ
src/
├── main/
│ ├── kotlin/com/condado/newsletter/
│ │ ├── CondadoNewsletterApplication.kt # App entry point
│ │ ├── config/ # Spring configuration classes
│ │ ├── controller/ # REST controllers
│ │ ├── service/ # Business logic
│ │ ├── repository/ # Spring Data JPA repositories
│ │ ├── model/ # JPA entities
│ │ ├── dto/ # Data Transfer Objects
│ │ └── scheduler/ # Scheduled tasks
│ │ ├── CondadoApplication.kt # App entry point
│ │ ├── config/ # Spring configuration classes
│ │ ├── controller/ # REST controllers
│ │ ├── service/ # Business logic
│ │ │ ├── EntityService.kt # CRUD for virtual entities
│ │ │ ├── EmailReaderService.kt # Reads emails via IMAP
│ │ │ ├── PromptBuilderService.kt # Builds AI prompt from entity + emails
│ │ │ ├── AiService.kt # Calls OpenAI API
│ │ │ └── EmailSenderService.kt # Sends email via SMTP
│ │ ├── repository/ # Spring Data JPA repositories
│ │ ├── model/ # JPA entities
│ │ ├── dto/ # Data Transfer Objects
│ │ └── scheduler/ # Scheduled tasks (trigger per entity)
│ └── resources/
│ ├── application.yml # Main config
── application-dev.yml # Dev profile config
│ └── templates/ # Email HTML templates (Thymeleaf)
│ ├── application.yml # Main config
── application-dev.yml # Dev profile config
└── test/
└── kotlin/com/condado/newsletter/ # Tests mirror main structure
└── kotlin/com/condado/newsletter/ # Tests mirror main structure
```
---
@@ -66,10 +80,10 @@ src/
./gradlew test
# Run a specific test class
./gradlew test --tests "com.condado.newsletter.service.NewsletterServiceTest"
./gradlew test --tests "com.condado.newsletter.service.PromptBuilderServiceTest"
# Generate OpenAPI docs (served at /swagger-ui.html when running)
./gradlew bootRun
# OpenAPI docs available at runtime
# http://localhost:8080/swagger-ui.html
```
---
@@ -79,7 +93,7 @@ src/
- Use **Kotlin idiomatic** style: data classes, extension functions, and null-safety operators.
- Prefer `val` over `var` wherever possible.
- Use **constructor injection** for dependencies (never field injection with `@Autowired`).
- All DTOs must be **data classes** with validation annotations (`javax.validation`).
- 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 `@Service` and **never** depend on controllers.
- Repositories must extend `JpaRepository<Entity, IdType>`.
@@ -87,20 +101,22 @@ src/
- All public functions must have **KDoc** comments.
- Use **`snake_case`** for database columns and **`camelCase`** for Kotlin properties.
- Keep controllers thin — business logic belongs in services.
- The AI prompt construction logic must live **exclusively** in `PromptBuilderService` — no other
class should build or modify prompt strings.
---
## Naming Conventions
| Artifact | Convention | Example |
|----------------|-----------------------------------|-----------------------------|
| Classes | PascalCase | `NewsletterService` |
| Functions | camelCase | `sendNewsletter()` |
| Variables | camelCase | `subscriberList` |
| Constants | SCREAMING_SNAKE_CASE | `MAX_RETRIES` |
| DB tables | snake_case (plural) | `newsletter_subscribers` |
| REST endpoints | kebab-case | `/api/v1/newsletter-issues` |
| Packages | lowercase | `com.condado.newsletter` |
| 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` |
---
@@ -116,15 +132,21 @@ src/
## Environment Variables
| Variable | Description |
|-----------------------|-----------------------------------|
| `SPRING_DATASOURCE_URL` | PostgreSQL connection URL |
| `SPRING_DATASOURCE_USERNAME` | DB username |
| `SPRING_DATASOURCE_PASSWORD` | DB password |
| `MAIL_HOST` | SMTP host |
| `MAIL_PORT` | SMTP port |
| `MAIL_USERNAME` | SMTP username |
| `MAIL_PASSWORD` | SMTP password |
| Variable | Description |
|---------------------------|------------------------------------------------------|
| `SPRING_DATASOURCE_URL` | PostgreSQL connection URL |
| `SPRING_DATASOURCE_USERNAME` | DB username |
| `SPRING_DATASOURCE_PASSWORD` | DB password |
| `MAIL_HOST` | SMTP host (for sending emails) |
| `MAIL_PORT` | SMTP port |
| `MAIL_USERNAME` | SMTP username (also used as IMAP login) |
| `MAIL_PASSWORD` | SMTP/IMAP password |
| `IMAP_HOST` | IMAP host (for reading the shared inbox) |
| `IMAP_PORT` | IMAP port (default: 993) |
| `IMAP_INBOX_FOLDER` | IMAP folder to read (default: `INBOX`) |
| `OPENAI_API_KEY` | OpenAI API key for AI generation |
| `OPENAI_MODEL` | OpenAI model to use (default: `gpt-4o`) |
| `API_KEY` | API key to protect the REST endpoints |
> ⚠️ Never hardcode credentials. Always use environment variables or a `.env` file (gitignored).
@@ -132,10 +154,44 @@ src/
## Key Domain Concepts
- **Subscriber:** A person who opted in to receive newsletters.
- **NewsletterIssue:** A single newsletter edition with a subject and HTML body.
- **Campaign:** A scheduled or triggered dispatch of a `NewsletterIssue` to a group of subscribers.
- **SendLog:** A record of each email send attempt (status: PENDING / SENT / FAILED).
- **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 `PromptBuilderService` from the
entity's profile + the `EmailContext`. 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].
```
---

872
INSTRUCTIONS.md Normal file
View File

@@ -0,0 +1,872 @@
# 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 project 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 | Security (API Key auth) | ⬜ Pending |
| 11 | Unit & integration tests | ⬜ Pending |
| 12 | Docker & deployment config | ⬜ 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 Project Structure
**Goal:** Generate the full Gradle project skeleton with all dependencies configured.
**What the AI should create:**
```
condado-news-letter/
├── build.gradle.kts
├── settings.gradle.kts
├── gradle/wrapper/
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── .gitignore
├── .env.example
└── src/
├── main/
│ ├── kotlin/com/condado/newsletter/
│ │ └── CondadoApplication.kt
│ └── resources/
│ ├── application.yml
│ └── application-dev.yml
└── test/
└── kotlin/com/condado/newsletter/
└── CondadoApplicationTests.kt
```
**Dependencies to include in `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` | API key authentication |
| `postgresql` | PostgreSQL JDBC driver |
| `jakarta.mail` / `angus-mail` | IMAP email reading |
| `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 |
**`.env.example` should contain:**
```env
SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/condado
SPRING_DATASOURCE_USERNAME=postgres
SPRING_DATASOURCE_PASSWORD=postgres
MAIL_HOST=smtp.example.com
MAIL_PORT=587
MAIL_USERNAME=company@example.com
MAIL_PASSWORD=secret
IMAP_HOST=imap.example.com
IMAP_PORT=993
IMAP_INBOX_FOLDER=INBOX
OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-4o
API_KEY=change-me
```
**Prompt to use with AI:**
> "Using the CLAUDE.md context, generate the full `build.gradle.kts`, `settings.gradle.kts`,
> `application.yml`, `application-dev.yml`, `.gitignore`, `.env.example`, and the main
> application entry point `CondadoApplication.kt`."
**Done when:**
- [ ] `./gradlew build` runs successfully (compile only, no logic yet).
- [ ] Application starts with `./gradlew bootRun` without errors.
- [ ] Swagger UI is accessible at `http://localhost:8080/swagger-ui.html`.
---
## 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/`):**
```kotlin
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:**
```kotlin
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:**
```kotlin
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 — Security (API Key Authentication)
**Goal:** Protect all API endpoints with a simple API key header.
**Approach:**
- Spring Security with a custom `OncePerRequestFilter`.
- Clients must send `X-API-KEY: <value>` header.
- Key is read from `API_KEY` environment variable.
- Swagger UI and OpenAPI spec (`/swagger-ui.html`, `/v3/api-docs/**`) are public.
**Prompt to use with AI:**
> "Using the CLAUDE.md context, add API key authentication with Spring Security. Create a
> custom filter that checks the `X-API-KEY` header against the `API_KEY` env var. Swagger UI
> paths must be excluded from authentication."
**Done when:**
- [ ] All endpoints return `401` without the correct `X-API-KEY` header.
- [ ] Swagger UI is still accessible without auth.
- [ ] API key is never hardcoded.
---
## Step 11 — Unit & Integration Tests
**Goal:** Test every service class and one integration test per controller.
| 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 |
| `VirtualEntityControllerTest` | Integration | CRUD endpoints, trigger endpoint |
| `DispatchLogControllerTest` | Integration | List all, list by entity |
**Prompt to use with AI:**
> "Using the CLAUDE.md context, generate unit tests for all service classes using MockK, and
> integration tests for controllers using `@SpringBootTest` with H2. Follow naming convention
> `should_[expectedBehavior]_when_[condition]`."
**Done when:**
- [ ] `./gradlew test` passes all tests green.
- [ ] Service class coverage ≥ 80%.
---
## Step 12 — Docker & Deployment Config
**Goal:** Containerize the app and provide a local dev stack.
**Files to create:**
```
condado-news-letter/
├── Dockerfile # Multi-stage build
├── docker-compose.yml # App + PostgreSQL + Mailhog (dev SMTP/IMAP)
└── docker-compose.prod.yml # App + PostgreSQL only
```
**Notes:**
- Use [Mailhog](https://github.com/mailhog/MailHog) in dev to capture outgoing emails
(SMTP on port 1025, web UI on port 8025).
- For IMAP in dev, consider [Greenmail](https://greenmail-mail-test.github.io/greenmail/) as
a local IMAP server for end-to-end testing.
**Prompt to use with AI:**
> "Using the CLAUDE.md context, create a multi-stage `Dockerfile` for the Spring Boot app, a
> `docker-compose.yml` for local dev (PostgreSQL + Mailhog + Greenmail), and a
> `docker-compose.prod.yml` for production (PostgreSQL only, env vars from host)."
**Done when:**
- [ ] `docker-compose up` starts the full stack.
- [ ] App connects to PostgreSQL container.
- [ ] Outgoing emails appear in Mailhog at `http://localhost:8025`.
- [ ] `docker build -t condado-newsletter .` succeeds.
---
## 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 | API key auth (not JWT) | Simple internal tool, can upgrade to OAuth2 later |
| 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 |
---
## Step 0 — Define the Project & Write CLAUDE.md
**Goal:** Establish the project scope and create the persistent AI instructions file.
**What was done:**
- Decided on Kotlin + Spring Boot 3.x as the core stack.
- Chose PostgreSQL for persistence, Spring Mail for email, and Gradle (Kotlin DSL) as the build tool.
- Defined the four core domain concepts: `Subscriber`, `NewsletterIssue`, `Campaign`, `SendLog`.
- Created `CLAUDE.md` with project structure, coding standards, naming conventions, and environment variables.
**Key decisions:**
- Use Gradle Kotlin DSL (`build.gradle.kts`) instead of Groovy DSL.
- Use MockK for tests, not Mockito (more idiomatic for Kotlin).
- Use Springdoc OpenAPI for automatic API documentation.
- Thymeleaf for HTML email templates.
**Output files:**
- `CLAUDE.md` ✅
---
## Step 1 — Scaffold the Project Structure
**Goal:** Generate the full Gradle project skeleton with all dependencies configured.
**What the AI should create:**
```
condado-news-letter/
├── build.gradle.kts
├── settings.gradle.kts
├── gradle/
│ └── wrapper/
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── .gitignore
├── .env.example
└── src/
├── main/
│ ├── kotlin/com/condado/newsletter/
│ │ └── CondadoNewsletterApplication.kt
│ └── resources/
│ ├── application.yml
│ └── application-dev.yml
└── test/
└── kotlin/com/condado/newsletter/
└── CondadoNewsletterApplicationTests.kt
```
**Dependencies to include in `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-thymeleaf` | HTML email templates |
| `thymeleaf-extras-spring6` | Thymeleaf + Spring 6 integration |
| `postgresql` | PostgreSQL JDBC driver |
| `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 |
**Prompt to use with AI:**
> "Using the CLAUDE.md context, generate the full `build.gradle.kts`, `settings.gradle.kts`, `application.yml`, `application-dev.yml`, `.gitignore`, `.env.example`, and the main application entry point `CondadoNewsletterApplication.kt`."
**Done when:**
- [ ] `./gradlew build` runs successfully (compile only, no logic yet).
- [ ] Application starts with `./gradlew bootRun` without errors.
- [ ] Swagger UI is accessible at `http://localhost:8080/swagger-ui.html`.
---
## Step 2 — Domain Model (JPA Entities)
**Goal:** Create all database entities and their relationships.
**Entities to create:**
### `Subscriber`
| Column | Type | Notes |
|-----------------|-------------|-------------------------------|
| `id` | UUID | Primary key, auto-generated |
| `email` | String | Unique, not null |
| `name` | String | Not null |
| `subscribed_at` | LocalDateTime | Auto-set on creation |
| `active` | Boolean | Default true |
### `NewsletterIssue`
| Column | Type | Notes |
|-----------------|-------------|-------------------------------|
| `id` | UUID | Primary key, auto-generated |
| `title` | String | Not null |
| `subject` | String | Email subject line |
| `html_body` | Text | Full HTML content |
| `created_at` | LocalDateTime | Auto-set on creation |
| `status` | Enum | DRAFT / READY / ARCHIVED |
### `Campaign`
| Column | Type | Notes |
|---------------------|---------------|------------------------------|
| `id` | UUID | Primary key |
| `newsletter_issue_id` | UUID (FK) | References `NewsletterIssue` |
| `scheduled_at` | LocalDateTime | When to send |
| `sent_at` | LocalDateTime | Nullable, set when sent |
| `status` | Enum | SCHEDULED / RUNNING / DONE / FAILED |
### `SendLog`
| Column | Type | Notes |
|----------------|---------------|-------------------------------|
| `id` | UUID | Primary key |
| `campaign_id` | UUID (FK) | References `Campaign` |
| `subscriber_id`| UUID (FK) | References `Subscriber` |
| `sent_at` | LocalDateTime | Nullable |
| `status` | Enum | PENDING / SENT / FAILED |
| `error_message`| String | Nullable, stores failure reason |
**Prompt to use with AI:**
> "Using the CLAUDE.md context, create all four JPA entities: `Subscriber`, `NewsletterIssue`, `Campaign`, and `SendLog`. Place them in the `model/` package. Use UUIDs as primary keys, Kotlin data classes where appropriate, and proper JPA annotations."
**Done when:**
- [ ] All four entity files exist in `src/main/kotlin/com/condado/newsletter/model/`.
- [ ] `./gradlew build` still compiles cleanly.
- [ ] Database tables are auto-created by Hibernate on startup (with `ddl-auto: create-drop` in dev profile).
---
## Step 3 — Repositories
**Goal:** Create Spring Data JPA repositories for each entity.
**Repositories to create:**
| Repository | Entity | Custom queries needed |
|-----------------------------|-------------------|------------------------------------------------|
| `SubscriberRepository` | `Subscriber` | `findByEmail()`, `findAllByActiveTrue()` |
| `NewsletterIssueRepository` | `NewsletterIssue` | `findAllByStatus()` |
| `CampaignRepository` | `Campaign` | `findAllByStatus()`, `findByScheduledAtBefore()` |
| `SendLogRepository` | `SendLog` | `findByCampaignId()`, `countByStatus()` |
**Prompt to use with AI:**
> "Using the CLAUDE.md context, create the four JPA repositories in the `repository/` package. Each must extend `JpaRepository` and include the custom query methods listed in the instructions."
**Done when:**
- [ ] All four repository files exist in `src/main/kotlin/com/condado/newsletter/repository/`.
- [ ] `./gradlew build` compiles cleanly.
---
## Step 4 — Services (Business Logic)
**Goal:** Implement the core business logic for each domain area.
**Services to create:**
### `SubscriberService`
- `subscribe(dto: SubscriberCreateDto): Subscriber` — register a new subscriber
- `unsubscribe(email: String)` — set `active = false`
- `listActive(): List<Subscriber>` — get all active subscribers
- `findByEmail(email: String): Subscriber`
### `NewsletterIssueService`
- `createIssue(dto: NewsletterIssueCreateDto): NewsletterIssue`
- `updateIssue(id: UUID, dto: NewsletterIssueUpdateDto): NewsletterIssue`
- `archiveIssue(id: UUID)`
- `listIssues(status: IssueStatus?): List<NewsletterIssue>`
### `CampaignService`
- `scheduleCampaign(dto: CampaignCreateDto): Campaign`
- `cancelCampaign(id: UUID)`
- `listCampaigns(status: CampaignStatus?): List<Campaign>`
- `triggerCampaign(id: UUID)` — manually kick off sending
### `EmailService`
- `sendNewsletterEmail(subscriber: Subscriber, issue: NewsletterIssue): Boolean`
- Uses `JavaMailSender` and Thymeleaf template engine
- Returns `true` on success, `false` on failure (logs error)
**Prompt to use with AI:**
> "Using the CLAUDE.md context, create the four service classes: `SubscriberService`, `NewsletterIssueService`, `CampaignService`, and `EmailService`. Place them in the `service/` package. Follow all coding standards from CLAUDE.md."
**Done when:**
- [ ] All four service files exist in `src/main/kotlin/com/condado/newsletter/service/`.
- [ ] `./gradlew build` compiles cleanly.
---
## Step 5 — REST Controllers & DTOs
**Goal:** Expose the service layer as a REST API with proper request/response DTOs.
**Controllers to create:**
| Controller | Base Path | Methods |
|-------------------------------|----------------------------|-----------------------------------|
| `SubscriberController` | `/api/v1/subscribers` | `POST`, `GET`, `DELETE /{email}` |
| `NewsletterIssueController` | `/api/v1/newsletter-issues`| `POST`, `GET`, `PUT /{id}`, `DELETE /{id}` |
| `CampaignController` | `/api/v1/campaigns` | `POST`, `GET`, `POST /{id}/trigger`, `DELETE /{id}` |
**DTOs to create (in `dto/` package):**
- `SubscriberCreateDto`, `SubscriberResponseDto`
- `NewsletterIssueCreateDto`, `NewsletterIssueUpdateDto`, `NewsletterIssueResponseDto`
- `CampaignCreateDto`, `CampaignResponseDto`
**Prompt to use with AI:**
> "Using the CLAUDE.md context, create the REST controllers and all DTOs. Controllers go in `controller/`, DTOs in `dto/`. All controllers must return `ResponseEntity<T>`. DTOs must use validation annotations."
**Done when:**
- [ ] All controller and DTO files exist.
- [ ] Swagger UI at `http://localhost:8080/swagger-ui.html` shows all endpoints.
- [ ] Manual test with a REST client (curl / Postman / Swagger UI) succeeds for basic CRUD.
---
## Step 6 — Email Sending (Spring Mail + Thymeleaf)
**Goal:** Send real HTML emails using a Thymeleaf template.
**What to create:**
- `src/main/resources/templates/newsletter-email.html` — the HTML email template
- `EmailService` (already defined in Step 4) — connect it to the template engine
**Template variables:**
- `${subscriber.name}` — recipient's name
- `${issue.title}` — newsletter title
- `${issue.htmlBody}` — main newsletter content
- `${unsubscribeUrl}` — one-click unsubscribe link
**Prompt to use with AI:**
> "Using the CLAUDE.md context, create the Thymeleaf HTML email template and complete the `EmailService` to render it and send via `JavaMailSender`. Include an unsubscribe link in the template."
**Done when:**
- [ ] Template file exists at `src/main/resources/templates/newsletter-email.html`.
- [ ] `EmailService.sendNewsletterEmail()` renders the template and sends via SMTP.
- [ ] Tested with a real SMTP server (e.g., Mailtrap or Gmail SMTP in dev).
---
## Step 7 — Scheduler (Automated Campaigns)
**Goal:** Automatically trigger campaigns at their `scheduled_at` time.
**What to create:**
- `NewsletterScheduler` class in `scheduler/` package
- Uses `@Scheduled(fixedDelay = 60000)` — checks every 60 seconds
- Finds all `SCHEDULED` campaigns where `scheduled_at <= now()`
- Calls `CampaignService.triggerCampaign()` for each
- Updates `SendLog` entries for every subscriber
**Prompt to use with AI:**
> "Using the CLAUDE.md context, create the `NewsletterScheduler` class. It should run every 60 seconds, find campaigns due to be sent, and dispatch emails to all active subscribers. Update `SendLog` with SENT or FAILED status for each attempt."
**Done when:**
- [ ] `NewsletterScheduler.kt` exists in `scheduler/` package.
- [ ] Scheduler correctly processes due campaigns.
- [ ] `SendLog` records are created for each send attempt.
- [ ] `@EnableScheduling` is added to the main application class or a config class.
---
## Step 8 — Security (API Key Authentication)
**Goal:** Protect the API with a simple API key header check.
**Approach:**
- Use Spring Security with a custom `OncePerRequestFilter`
- Clients must pass `X-API-KEY: <key>` header
- The key is stored in an environment variable `API_KEY`
- Public endpoints (unsubscribe link) are excluded from auth
**Prompt to use with AI:**
> "Using the CLAUDE.md context, add API key authentication using Spring Security. Create a custom filter that checks the `X-API-KEY` header. The key must come from the `API_KEY` environment variable. Exclude `GET /api/v1/subscribers/unsubscribe/**` from auth."
**Done when:**
- [ ] All API endpoints return `401 Unauthorized` without a valid API key.
- [ ] Unsubscribe endpoint works without auth.
- [ ] API key is read from environment variable, never hardcoded.
---
## Step 9 — Unit & Integration Tests
**Goal:** Test every service class and at least one integration test per controller.
**Tests to create:**
| Test class | Type | Covers |
|-----------------------------------|-------------|---------------------------------|
| `SubscriberServiceTest` | Unit | subscribe, unsubscribe, list |
| `NewsletterIssueServiceTest` | Unit | create, update, archive |
| `CampaignServiceTest` | Unit | schedule, cancel, trigger |
| `EmailServiceTest` | Unit | template rendering, send logic |
| `SubscriberControllerTest` | Integration | POST /api/v1/subscribers |
| `NewsletterIssueControllerTest` | Integration | CRUD /api/v1/newsletter-issues |
| `CampaignControllerTest` | Integration | POST, trigger, cancel |
**Prompt to use with AI:**
> "Using the CLAUDE.md context, generate unit tests for all service classes using MockK, and integration tests for all controllers using `@SpringBootTest` with H2 in-memory database. Follow the naming convention `should_[expectedBehavior]_when_[condition]`."
**Done when:**
- [ ] `./gradlew test` passes with all tests green.
- [ ] Code coverage for service classes is ≥ 80%.
---
## Step 10 — Docker & Deployment Config
**Goal:** Containerize the application and provide a `docker-compose.yml` for local development.
**What to create:**
```
condado-news-letter/
├── Dockerfile
├── docker-compose.yml # App + PostgreSQL + Mailhog (dev SMTP)
└── docker-compose.prod.yml # App + PostgreSQL only
```
**Prompt to use with AI:**
> "Using the CLAUDE.md context, create a multi-stage `Dockerfile` for the Spring Boot app, a `docker-compose.yml` for local development (includes PostgreSQL and Mailhog for email testing), and a `docker-compose.prod.yml` for production (PostgreSQL only, env vars from host)."
**Done when:**
- [ ] `docker-compose up` starts the full stack.
- [ ] App connects to the PostgreSQL container.
- [ ] Emails sent in dev are captured by Mailhog at `http://localhost:8025`.
- [ ] `./gradlew build && docker build -t condado-newsletter .` succeeds.
---
## Notes & Decisions Log
> Use this section to record important decisions made during the build. Add entries as you go.
| 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 | Thymeleaf for email templates | Native Spring Boot support |
| 2026-03-26 | API key auth (not JWT) for simplicity in step 1 | Can be upgraded to OAuth2 later |