- 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.
40 KiB
Build Instructions — Condado Abaixo da Média SA Email Bot
This file documents every step to build the project from scratch using AI.
After each session, both this file and CLAUDE.md will be updated to reflect progress.
What Are We Building?
A group of friends created a fictional company called "Condado Abaixo da Média SA". Their dynamic works entirely over email — they write to each other in an extremely formal, corporate tone, but the content is completely casual and nonsensical (inside jokes, mundane topics, etc.). The contrast is the joke.
This service allows registering virtual employees of that fictional company. Each virtual employee is an AI-powered entity that:
- Has a name, email address, job title, and a personality description.
- Has a scheduled time to send emails (e.g., every Monday at 9am).
- Has a configurable context window (e.g., "read emails from the last 3 days") so it can react to what was already said in the thread.
- At the scheduled time: reads recent emails from the shared inbox via IMAP → builds a prompt → sends the prompt to the OpenAI API → sends the generated email via SMTP.
How to Use This File
- Follow steps in order — each step builds on the previous one.
- At the start of each AI session, share the contents of
CLAUDE.mdfor context. - After completing each step, mark it ✅ Done and note any decisions made.
- If anything changes (new library, schema change, etc.), update
CLAUDE.mdtoo.
Progress Tracker
| Step | Description | Status |
|---|---|---|
| 0 | Define project & write CLAUDE.md | ✅ Done |
| 1 | Scaffold 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.mdwith the prompt template, project structure, coding standards, and env vars.
Key decisions:
- Use Gradle Kotlin DSL (
build.gradle.kts) instead of Groovy DSL. - Use MockK for tests, not Mockito (more idiomatic for Kotlin).
- Use OpenAI
gpt-4oas the AI model (configurable via env var). - No Thymeleaf — emails are plain text or simple HTML generated entirely by the AI.
- The prompt template is defined in
CLAUDE.mdand must be respected exactly.
Output files:
CLAUDE.md✅
Step 1 — Scaffold the 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:
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 pointCondadoApplication.kt."
Done when:
./gradlew buildruns successfully (compile only, no logic yet).- Application starts with
./gradlew bootRunwithout 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:
VirtualEntityandDispatchLog. Place them in themodel/package. Use UUIDs as primary keys and proper JPA annotations.DispatchLoghas a@ManyToOnerelationship toVirtualEntity."
Done when:
- Both entity files exist in
src/main/kotlin/com/condado/newsletter/model/. ./gradlew buildcompiles cleanly.- Tables are auto-created by Hibernate on startup (
ddl-auto: create-dropin dev).
Step 3 — Repositories
Goal: Create Spring Data JPA repositories for each entity.
| Repository | Entity | Custom queries needed |
|---|---|---|
VirtualEntityRepository |
VirtualEntity |
findAllByActiveTrue(), findByEmail() |
DispatchLogRepository |
DispatchLog |
findAllByEntityId(), findTopByEntityIdOrderByDispatchedAtDesc() |
Prompt to use with AI:
"Using the CLAUDE.md context, create the two JPA repositories in the
repository/package. Each must extendJpaRepositoryand include the custom query methods listed in the instructions."
Done when:
- Both repository files exist in
src/main/kotlin/com/condado/newsletter/repository/. ./gradlew buildcompiles cleanly.
Step 4 — Email Reader Service (IMAP)
Goal: Read recent emails from the shared company inbox via IMAP to use as AI context.
Class: EmailReaderService in service/ package.
Responsibilities:
- Connect to the IMAP server using credentials from environment variables.
- Fetch all emails from the configured folder received within the last
Ndays. - Return a list of
EmailContextdata objects, each containing:from: String— sender name/addresssubject: String— email subjectbody: String— plain text body (strip HTML if needed)receivedAt: LocalDateTime
- Sort results from oldest to newest (chronological order, for natural prompt reading).
- Handle IMAP errors gracefully — log the error and return an empty list rather than crashing.
Data class to create (in dto/ or model/):
data class EmailContext(
val from: String,
val subject: String,
val body: String,
val receivedAt: LocalDateTime
)
Prompt to use with AI:
"Using the CLAUDE.md context, create the
EmailReaderServiceclass inservice/. It must connect to an IMAP server using env vars (IMAP_HOST,IMAP_PORT,MAIL_USERNAME,MAIL_PASSWORD,IMAP_INBOX_FOLDER), fetch emails from the last N days, and return them as a list ofEmailContextdata objects sorted chronologically. Use Jakarta Mail."
Done when:
EmailReaderService.ktexists inservice/.EmailContext.ktdata class exists.- Service reads real emails when tested against a live IMAP account.
- Returns empty list (not exception) on connection failure.
Step 5 — Prompt Builder Service
Goal: Transform a VirtualEntity + a list of EmailContext into a final AI prompt string.
Class: PromptBuilderService in service/ package.
Rule: This is the ONLY place in the codebase where prompt strings are built. No other class may construct or modify prompts.
The prompt template is defined in CLAUDE.md under "The Prompt Template (Core Logic)".
It must be followed exactly, with the entity fields and email context filled in dynamically.
Method signature:
fun buildPrompt(entity: VirtualEntity, emailContext: List<EmailContext>): String
Prompt to use with AI:
"Using the CLAUDE.md context, create
PromptBuilderServiceinservice/. It must implementbuildPrompt(entity, emailContext)following the prompt template defined in CLAUDE.md exactly. This is the only class allowed to build prompt strings."
Done when:
PromptBuilderService.ktexists inservice/.- Output prompt matches the template from
CLAUDE.mdwith fields correctly substituted. - Unit test verifies the prompt structure.
Step 6 — AI Service (OpenAI Integration)
Goal: Send the prompt to OpenAI and get back the generated email text.
Class: AiService in service/ package.
Responsibilities:
- Call the OpenAI Chat Completions API (
POST https://api.openai.com/v1/chat/completions). - Use the model configured in
OPENAI_MODELenv var (default:gpt-4o). - Send the prompt as a
usermessage. - Return the AI's response as a plain
String. - Parse the response to extract
subjectandbody— the AI should be instructed to return them in a structured format:SUBJECT: <generated subject> BODY: <generated email body> - Handle API errors gracefully — throw a descriptive exception that
DispatchLogcan record.
HTTP Client: Use Spring's RestClient (Spring Boot 3.2+) — do NOT use WebClient or Feign.
Prompt to use with AI:
"Using the CLAUDE.md context, create
AiServiceinservice/. It must call the OpenAI Chat Completions API using Spring'sRestClient, using theOPENAI_API_KEYandOPENAI_MODELenv vars. Return the AI's text response. Also implement aparseResponse(raw: String)method that extracts the subject and body from a response formatted asSUBJECT: ...\nBODY:\n...."
Done when:
AiService.ktexists inservice/.- Calling the service with a real API key returns a valid AI-generated email.
parseResponse()correctly extracts subject and body.
Step 7 — Email Sender Service (SMTP)
Goal: Send the AI-generated email via SMTP using Spring Mail.
Class: EmailSenderService in service/ package.
Method signature:
fun send(from: String, to: List<String>, subject: String, body: String)
fromis theVirtualEntity.email(the fictional employee's address).tois the list of all real participants' emails (loaded from config or DB — TBD in Step 9).bodymay be plain text or simple HTML — send as bothtext/plainandtext/html(multipart).- Log every send attempt.
Note on recipients: For now, the list of recipients (the real friends' emails) will be stored
as a comma-separated string in application.yml under app.recipients. This can be made dynamic later.
Prompt to use with AI:
"Using the CLAUDE.md context, create
EmailSenderServiceinservice/. It must useJavaMailSenderto send emails. Thefromaddress comes from the entity, thetolist comes fromapp.recipientsin config, and the body is sent as both plain text and HTML."
Done when:
EmailSenderService.ktexists inservice/.- Email is received in a real inbox when tested with valid SMTP credentials.
Step 8 — Scheduler (Trigger Per Entity)
Goal: Automatically trigger each active VirtualEntity at its configured schedule.
Class: EntityScheduler in scheduler/ package.
Approach:
- Spring's
@Scheduledwith a fixed-rate tick (every 60 seconds) checks which entities are due. - Use a
ScheduledTaskRegistrar(or dynamic scheduling) to register a task per entity using itsschedule_cronexpression. - When triggered, orchestrate the full pipeline:
- Read emails via
EmailReaderService(usingentity.contextWindowDays). - Build prompt via
PromptBuilderService. - Call AI via
AiService. - Parse AI response (subject + body).
- Send email via
EmailSenderService. - Save a
DispatchLogrecord with status SENT or FAILED.
- Read emails via
- If the pipeline fails at any step, save a
DispatchLogwith status FAILED and the error message.
Prompt to use with AI:
"Using the CLAUDE.md context, create
EntitySchedulerinscheduler/. It must dynamically schedule a cron job per activeVirtualEntityusingSchedulingConfigurer. When triggered, it must run the full pipeline: read emails → build prompt → call AI → send email → saveDispatchLog. Handle failures gracefully and always write aDispatchLog."
Done when:
EntityScheduler.ktexists inscheduler/.@EnableSchedulingis present on the main app or a config class.- An active entity triggers at its scheduled time and a
DispatchLogrecord is created. - End-to-end test: entity fires → email arrives in inbox.
Step 9 — REST Controllers & DTOs
Goal: Expose CRUD operations for VirtualEntity and read-only access to DispatchLog.
Controllers:
VirtualEntityController — /api/v1/virtual-entities
| Method | Path | Description |
|---|---|---|
| POST | / |
Create a new virtual entity |
| GET | / |
List all virtual entities |
| GET | /{id} |
Get one entity by ID |
| PUT | /{id} |
Update an entity |
| DELETE | /{id} |
Deactivate an entity (soft delete) |
| POST | /{id}/trigger |
Manually trigger the entity pipeline |
DispatchLogController — /api/v1/dispatch-logs
| Method | Path | Description |
|---|---|---|
| GET | / |
List all dispatch logs |
| GET | /entity/{id} |
List logs for a specific entity |
DTOs to create (in dto/ package):
VirtualEntityCreateDto— name, email, jobTitle, personality, scheduleCron, contextWindowDaysVirtualEntityUpdateDto— same fields, all optionalVirtualEntityResponseDto— full entity fields + id + createdAtDispatchLogResponseDto— all DispatchLog fields
Prompt to use with AI:
"Using the CLAUDE.md context, create
VirtualEntityControllerandDispatchLogControllerincontroller/, and all DTOs indto/. Controllers returnResponseEntity<T>. The/triggerendpoint manually runs the entity pipeline. DTOs use validation annotations."
Done when:
- All controller and DTO files exist.
- Swagger UI shows all endpoints.
- CRUD via Swagger UI or Postman works end-to-end.
Step 10 — 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_KEYenvironment 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-KEYheader against theAPI_KEYenv var. Swagger UI paths must be excluded from authentication."
Done when:
- All endpoints return
401without the correctX-API-KEYheader. - 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
@SpringBootTestwith H2. Follow naming conventionshould_[expectedBehavior]_when_[condition]."
Done when:
./gradlew testpasses 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 in dev to capture outgoing emails (SMTP on port 1025, web UI on port 8025).
- For IMAP in dev, consider 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
Dockerfilefor the Spring Boot app, adocker-compose.ymlfor local dev (PostgreSQL + Mailhog + Greenmail), and adocker-compose.prod.ymlfor production (PostgreSQL only, env vars from host)."
Done when:
docker-compose upstarts 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.mdwith 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 pointCondadoNewsletterApplication.kt."
Done when:
./gradlew buildruns successfully (compile only, no logic yet).- Application starts with
./gradlew bootRunwithout 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, andSendLog. Place them in themodel/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 buildstill compiles cleanly.- Database tables are auto-created by Hibernate on startup (with
ddl-auto: create-dropin 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 extendJpaRepositoryand 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 buildcompiles 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 subscriberunsubscribe(email: String)— setactive = falselistActive(): List<Subscriber>— get all active subscribersfindByEmail(email: String): Subscriber
NewsletterIssueService
createIssue(dto: NewsletterIssueCreateDto): NewsletterIssueupdateIssue(id: UUID, dto: NewsletterIssueUpdateDto): NewsletterIssuearchiveIssue(id: UUID)listIssues(status: IssueStatus?): List<NewsletterIssue>
CampaignService
scheduleCampaign(dto: CampaignCreateDto): CampaigncancelCampaign(id: UUID)listCampaigns(status: CampaignStatus?): List<Campaign>triggerCampaign(id: UUID)— manually kick off sending
EmailService
sendNewsletterEmail(subscriber: Subscriber, issue: NewsletterIssue): Boolean- Uses
JavaMailSenderand Thymeleaf template engine - Returns
trueon success,falseon failure (logs error)
Prompt to use with AI:
"Using the CLAUDE.md context, create the four service classes:
SubscriberService,NewsletterIssueService,CampaignService, andEmailService. Place them in theservice/package. Follow all coding standards from CLAUDE.md."
Done when:
- All four service files exist in
src/main/kotlin/com/condado/newsletter/service/. ./gradlew buildcompiles 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,SubscriberResponseDtoNewsletterIssueCreateDto,NewsletterIssueUpdateDto,NewsletterIssueResponseDtoCampaignCreateDto,CampaignResponseDto
Prompt to use with AI:
"Using the CLAUDE.md context, create the REST controllers and all DTOs. Controllers go in
controller/, DTOs indto/. All controllers must returnResponseEntity<T>. DTOs must use validation annotations."
Done when:
- All controller and DTO files exist.
- Swagger UI at
http://localhost:8080/swagger-ui.htmlshows 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 templateEmailService(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
EmailServiceto render it and send viaJavaMailSender. 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:
NewsletterSchedulerclass inscheduler/package- Uses
@Scheduled(fixedDelay = 60000)— checks every 60 seconds - Finds all
SCHEDULEDcampaigns wherescheduled_at <= now() - Calls
CampaignService.triggerCampaign()for each - Updates
SendLogentries for every subscriber
Prompt to use with AI:
"Using the CLAUDE.md context, create the
NewsletterSchedulerclass. It should run every 60 seconds, find campaigns due to be sent, and dispatch emails to all active subscribers. UpdateSendLogwith SENT or FAILED status for each attempt."
Done when:
NewsletterScheduler.ktexists inscheduler/package.- Scheduler correctly processes due campaigns.
SendLogrecords are created for each send attempt.@EnableSchedulingis 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-KEYheader. The key must come from theAPI_KEYenvironment variable. ExcludeGET /api/v1/subscribers/unsubscribe/**from auth."
Done when:
- All API endpoints return
401 Unauthorizedwithout 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
@SpringBootTestwith H2 in-memory database. Follow the naming conventionshould_[expectedBehavior]_when_[condition]."
Done when:
./gradlew testpasses 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
Dockerfilefor the Spring Boot app, adocker-compose.ymlfor local development (includes PostgreSQL and Mailhog for email testing), and adocker-compose.prod.ymlfor production (PostgreSQL only, env vars from host)."
Done when:
docker-compose upstarts 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 |