From d834ca85b08ce442d0f3dc50d87571f8a25586af Mon Sep 17 00:00:00 2001 From: Gabriel Sancho Date: Thu, 26 Mar 2026 14:07:59 -0300 Subject: [PATCH] =?UTF-8?q?Add=20build=20instructions=20and=20project=20st?= =?UTF-8?q?ructure=20for=20Condado=20Abaixo=20da=20M=C3=A9dia=20SA=20Email?= =?UTF-8?q?=20Bot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- CLAUDE.md | 160 ++++++--- INSTRUCTIONS.md | 872 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 980 insertions(+), 52 deletions(-) create mode 100644 INSTRUCTIONS.md diff --git a/CLAUDE.md b/CLAUDE.md index fed2e9e..285e319 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` with explicit HTTP status codes. - Services must be annotated with `@Service` and **never** depend on controllers. - Repositories must extend `JpaRepository`. @@ -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]. +``` --- diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md new file mode 100644 index 0000000..ddaae77 --- /dev/null +++ b/INSTRUCTIONS.md @@ -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): 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: + 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, 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`. 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: ` 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` — 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` + +### `CampaignService` +- `scheduleCampaign(dto: CampaignCreateDto): Campaign` +- `cancelCampaign(id: UUID)` +- `listCampaigns(status: CampaignStatus?): List` +- `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`. 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: ` 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 |