# 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 |