Files
condado-newsletter/INSTRUCTIONS.md
Gabriel Sancho d834ca85b0 Add build instructions and project structure for Condado Abaixo da Média SA Email Bot
- Created INSTRUCTIONS.md detailing project goals, usage, and progress tracking.
- Defined project scope, technology stack, and core domain concepts.
- Outlined step-by-step build process from scaffolding to deployment.
- Included detailed descriptions for each step, including entity models, services, and controllers.
- Established a decision log to track key choices made during development.
2026-03-26 14:07:59 -03:00

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:

  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:

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/):

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:

fun buildPrompt(entity: VirtualEntity, emailContext: List<EmailContext>): String

Prompt to use with AI:

"Using the CLAUDE.md context, create PromptBuilderService in service/. It must implement buildPrompt(entity, emailContext) following the prompt template defined in CLAUDE.md exactly. This is the only class allowed to build prompt strings."

Done when:

  • PromptBuilderService.kt exists in service/.
  • Output prompt matches the template from CLAUDE.md with fields correctly substituted.
  • Unit test verifies the prompt structure.

Step 6 — AI Service (OpenAI Integration)

Goal: Send the prompt to OpenAI and get back the generated email text.

Class: AiService in service/ package.

Responsibilities:

  • Call the OpenAI Chat Completions API (POST https://api.openai.com/v1/chat/completions).
  • Use the model configured in OPENAI_MODEL env var (default: gpt-4o).
  • Send the prompt as a user message.
  • Return the AI's response as a plain String.
  • Parse the response to extract subject and body — the AI should be instructed to return them in a structured format:
    SUBJECT: <generated subject>
    BODY:
    <generated email body>
    
  • Handle API errors gracefully — throw a descriptive exception that DispatchLog can record.

HTTP Client: Use Spring's RestClient (Spring Boot 3.2+) — do NOT use WebClient or Feign.

Prompt to use with AI:

"Using the CLAUDE.md context, create AiService in service/. It must call the OpenAI Chat Completions API using Spring's RestClient, using the OPENAI_API_KEY and OPENAI_MODEL env vars. Return the AI's text response. Also implement a parseResponse(raw: String) method that extracts the subject and body from a response formatted as SUBJECT: ...\nBODY:\n...."

Done when:

  • AiService.kt exists in service/.
  • Calling the service with a real API key returns a valid AI-generated email.
  • parseResponse() correctly extracts subject and body.

Step 7 — Email Sender Service (SMTP)

Goal: Send the AI-generated email via SMTP using Spring Mail.

Class: EmailSenderService in service/ package.

Method signature:

fun send(from: String, to: List<String>, subject: String, body: String)
  • from is the VirtualEntity.email (the fictional employee's address).
  • to is the list of all real participants' emails (loaded from config or DB — TBD in Step 9).
  • body may be plain text or simple HTML — send as both text/plain and text/html (multipart).
  • Log every send attempt.

Note on recipients: For now, the list of recipients (the real friends' emails) will be stored as a comma-separated string in application.yml under app.recipients. This can be made dynamic later.

Prompt to use with AI:

"Using the CLAUDE.md context, create EmailSenderService in service/. It must use JavaMailSender to send emails. The from address comes from the entity, the to list comes from app.recipients in config, and the body is sent as both plain text and HTML."

Done when:

  • EmailSenderService.kt exists in service/.
  • Email is received in a real inbox when tested with valid SMTP credentials.

Step 8 — Scheduler (Trigger Per Entity)

Goal: Automatically trigger each active VirtualEntity at its configured schedule.

Class: EntityScheduler in scheduler/ package.

Approach:

  • Spring's @Scheduled with a fixed-rate tick (every 60 seconds) checks which entities are due.
  • Use a ScheduledTaskRegistrar (or dynamic scheduling) to register a task per entity using its schedule_cron expression.
  • When triggered, orchestrate the full pipeline:
    1. Read emails via EmailReaderService (using entity.contextWindowDays).
    2. Build prompt via PromptBuilderService.
    3. Call AI via AiService.
    4. Parse AI response (subject + body).
    5. Send email via EmailSenderService.
    6. Save a DispatchLog record with status SENT or FAILED.
  • If the pipeline fails at any step, save a DispatchLog with status FAILED and the error message.

Prompt to use with AI:

"Using the CLAUDE.md context, create EntityScheduler in scheduler/. It must dynamically schedule a cron job per active VirtualEntity using SchedulingConfigurer. When triggered, it must run the full pipeline: read emails → build prompt → call AI → send email → save DispatchLog. Handle failures gracefully and always write a DispatchLog."

Done when:

  • EntityScheduler.kt exists in scheduler/.
  • @EnableScheduling is present on the main app or a config class.
  • An active entity triggers at its scheduled time and a DispatchLog record is created.
  • End-to-end test: entity fires → email arrives in inbox.

Step 9 — REST Controllers & DTOs

Goal: Expose CRUD operations for VirtualEntity and read-only access to DispatchLog.

Controllers:

VirtualEntityController/api/v1/virtual-entities

Method Path Description
POST / Create a new virtual entity
GET / List all virtual entities
GET /{id} Get one entity by ID
PUT /{id} Update an entity
DELETE /{id} Deactivate an entity (soft delete)
POST /{id}/trigger Manually trigger the entity pipeline

DispatchLogController/api/v1/dispatch-logs

Method Path Description
GET / List all dispatch logs
GET /entity/{id} List logs for a specific entity

DTOs to create (in dto/ package):

  • VirtualEntityCreateDto — name, email, jobTitle, personality, scheduleCron, contextWindowDays
  • VirtualEntityUpdateDto — same fields, all optional
  • VirtualEntityResponseDto — full entity fields + id + createdAt
  • DispatchLogResponseDto — all DispatchLog fields

Prompt to use with AI:

"Using the CLAUDE.md context, create VirtualEntityController and DispatchLogController in controller/, and all DTOs in dto/. Controllers return ResponseEntity<T>. The /trigger endpoint manually runs the entity pipeline. DTOs use validation annotations."

Done when:

  • All controller and DTO files exist.
  • Swagger UI shows all endpoints.
  • CRUD via Swagger UI or Postman works end-to-end.

Step 10 — Security (API Key Authentication)

Goal: Protect all API endpoints with a simple API key header.

Approach:

  • Spring Security with a custom OncePerRequestFilter.
  • Clients must send X-API-KEY: <value> header.
  • Key is read from API_KEY environment variable.
  • Swagger UI and OpenAPI spec (/swagger-ui.html, /v3/api-docs/**) are public.

Prompt to use with AI:

"Using the CLAUDE.md context, add API key authentication with Spring Security. Create a custom filter that checks the X-API-KEY header against the API_KEY env var. Swagger UI paths must be excluded from authentication."

Done when:

  • All endpoints return 401 without the correct X-API-KEY header.
  • Swagger UI is still accessible without auth.
  • API key is never hardcoded.

Step 11 — Unit & Integration Tests

Goal: Test every service class and one integration test per controller.

Test Class Type Covers
EmailReaderServiceTest Unit IMAP fetch, empty list on error
PromptBuilderServiceTest Unit Prompt matches template, fields substituted
AiServiceTest Unit API call mocked, parseResponse parsing
EmailSenderServiceTest Unit JavaMailSender called with correct args
VirtualEntityControllerTest Integration CRUD endpoints, trigger endpoint
DispatchLogControllerTest Integration List all, list by entity

Prompt to use with AI:

"Using the CLAUDE.md context, generate unit tests for all service classes using MockK, and integration tests for controllers using @SpringBootTest with H2. Follow naming convention should_[expectedBehavior]_when_[condition]."

Done when:

  • ./gradlew test passes all tests green.
  • Service class coverage ≥ 80%.

Step 12 — Docker & Deployment Config

Goal: Containerize the app and provide a local dev stack.

Files to create:

condado-news-letter/
├── Dockerfile                  # Multi-stage build
├── docker-compose.yml          # App + PostgreSQL + Mailhog (dev SMTP/IMAP)
└── docker-compose.prod.yml     # App + PostgreSQL only

Notes:

  • Use Mailhog 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 Dockerfile for the Spring Boot app, a docker-compose.yml for local dev (PostgreSQL + Mailhog + Greenmail), and a docker-compose.prod.yml for production (PostgreSQL only, env vars from host)."

Done when:

  • docker-compose up starts the full stack.
  • App connects to PostgreSQL container.
  • Outgoing emails appear in Mailhog at http://localhost:8025.
  • docker build -t condado-newsletter . succeeds.

Notes & Decisions Log

Date Decision Reason
2026-03-26 Chose Kotlin + Spring Boot 3.x Modern, type-safe, great Spring support
2026-03-26 MockK over Mockito More idiomatic for Kotlin
2026-03-26 UUID as primary keys Better for distributed systems
2026-03-26 No Thymeleaf — AI generates email content directly Email body is AI-produced, no template needed
2026-03-26 API key auth (not JWT) Simple internal tool, can upgrade to OAuth2 later
2026-03-26 Use Spring RestClient for OpenAI (not WebClient) Spring Boot 3.2+ preferred HTTP client
2026-03-26 Recipients stored in app.recipients config Simple starting point, can be made dynamic later
2026-03-26 PromptBuilderService is the only prompt builder Keeps prompt logic centralized and testable
2026-03-26 AI must format response as SUBJECT: ...\nBODY:\n... Allows reliable parsing of subject vs body

Step 0 — Define the Project & Write CLAUDE.md

Goal: Establish the project scope and create the persistent AI instructions file.

What was done:

  • Decided on Kotlin + Spring Boot 3.x as the core stack.
  • Chose PostgreSQL for persistence, Spring Mail for email, and Gradle (Kotlin DSL) as the build tool.
  • Defined the four core domain concepts: Subscriber, NewsletterIssue, Campaign, SendLog.
  • Created CLAUDE.md with project structure, coding standards, naming conventions, and environment variables.

Key decisions:

  • Use Gradle Kotlin DSL (build.gradle.kts) instead of Groovy DSL.
  • Use MockK for tests, not Mockito (more idiomatic for Kotlin).
  • Use Springdoc OpenAPI for automatic API documentation.
  • Thymeleaf for HTML email templates.

Output files:

  • CLAUDE.md

Step 1 — Scaffold the Project Structure

Goal: Generate the full Gradle project skeleton with all dependencies configured.

What the AI should create:

condado-news-letter/
├── build.gradle.kts
├── settings.gradle.kts
├── gradle/
│   └── wrapper/
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── .gitignore
├── .env.example
└── src/
    ├── main/
    │   ├── kotlin/com/condado/newsletter/
    │   │   └── CondadoNewsletterApplication.kt
    │   └── resources/
    │       ├── application.yml
    │       └── application-dev.yml
    └── test/
        └── kotlin/com/condado/newsletter/
            └── CondadoNewsletterApplicationTests.kt

Dependencies to include in build.gradle.kts:

Dependency Purpose
spring-boot-starter-web REST API
spring-boot-starter-data-jpa Database access (JPA/Hibernate)
spring-boot-starter-mail Email sending via SMTP
spring-boot-starter-validation DTO validation
spring-boot-starter-thymeleaf HTML email templates
thymeleaf-extras-spring6 Thymeleaf + Spring 6 integration
postgresql PostgreSQL JDBC driver
springdoc-openapi-starter-webmvc-ui Swagger UI / OpenAPI docs
kotlin-reflect Required by Spring for Kotlin
jackson-module-kotlin JSON serialization for Kotlin
h2 (testRuntimeOnly) In-memory DB for tests
spring-boot-starter-test JUnit 5 test support
mockk Kotlin mocking library
springmockk MockK integration for Spring

Prompt to use with AI:

"Using the CLAUDE.md context, generate the full build.gradle.kts, settings.gradle.kts, application.yml, application-dev.yml, .gitignore, .env.example, and the main application entry point CondadoNewsletterApplication.kt."

Done when:

  • ./gradlew build runs successfully (compile only, no logic yet).
  • Application starts with ./gradlew bootRun without errors.
  • Swagger UI is accessible at http://localhost:8080/swagger-ui.html.

Step 2 — Domain Model (JPA Entities)

Goal: Create all database entities and their relationships.

Entities to create:

Subscriber

Column Type Notes
id UUID Primary key, auto-generated
email String Unique, not null
name String Not null
subscribed_at LocalDateTime Auto-set on creation
active Boolean Default true

NewsletterIssue

Column Type Notes
id UUID Primary key, auto-generated
title String Not null
subject String Email subject line
html_body Text Full HTML content
created_at LocalDateTime Auto-set on creation
status Enum DRAFT / READY / ARCHIVED

Campaign

Column Type Notes
id UUID Primary key
newsletter_issue_id UUID (FK) References NewsletterIssue
scheduled_at LocalDateTime When to send
sent_at LocalDateTime Nullable, set when sent
status Enum SCHEDULED / RUNNING / DONE / FAILED

SendLog

Column Type Notes
id UUID Primary key
campaign_id UUID (FK) References Campaign
subscriber_id UUID (FK) References Subscriber
sent_at LocalDateTime Nullable
status Enum PENDING / SENT / FAILED
error_message String Nullable, stores failure reason

Prompt to use with AI:

"Using the CLAUDE.md context, create all four JPA entities: Subscriber, NewsletterIssue, Campaign, and SendLog. Place them in the model/ package. Use UUIDs as primary keys, Kotlin data classes where appropriate, and proper JPA annotations."

Done when:

  • All four entity files exist in src/main/kotlin/com/condado/newsletter/model/.
  • ./gradlew build still compiles cleanly.
  • Database tables are auto-created by Hibernate on startup (with ddl-auto: create-drop in dev profile).

Step 3 — Repositories

Goal: Create Spring Data JPA repositories for each entity.

Repositories to create:

Repository Entity Custom queries needed
SubscriberRepository Subscriber findByEmail(), findAllByActiveTrue()
NewsletterIssueRepository NewsletterIssue findAllByStatus()
CampaignRepository Campaign findAllByStatus(), findByScheduledAtBefore()
SendLogRepository SendLog findByCampaignId(), countByStatus()

Prompt to use with AI:

"Using the CLAUDE.md context, create the four JPA repositories in the repository/ package. Each must extend JpaRepository and include the custom query methods listed in the instructions."

Done when:

  • All four repository files exist in src/main/kotlin/com/condado/newsletter/repository/.
  • ./gradlew build compiles cleanly.

Step 4 — Services (Business Logic)

Goal: Implement the core business logic for each domain area.

Services to create:

SubscriberService

  • subscribe(dto: SubscriberCreateDto): Subscriber — register a new subscriber
  • unsubscribe(email: String) — set active = false
  • listActive(): List<Subscriber> — get all active subscribers
  • findByEmail(email: String): Subscriber

NewsletterIssueService

  • createIssue(dto: NewsletterIssueCreateDto): NewsletterIssue
  • updateIssue(id: UUID, dto: NewsletterIssueUpdateDto): NewsletterIssue
  • archiveIssue(id: UUID)
  • listIssues(status: IssueStatus?): List<NewsletterIssue>

CampaignService

  • scheduleCampaign(dto: CampaignCreateDto): Campaign
  • cancelCampaign(id: UUID)
  • listCampaigns(status: CampaignStatus?): List<Campaign>
  • triggerCampaign(id: UUID) — manually kick off sending

EmailService

  • sendNewsletterEmail(subscriber: Subscriber, issue: NewsletterIssue): Boolean
  • Uses JavaMailSender and Thymeleaf template engine
  • Returns true on success, false on failure (logs error)

Prompt to use with AI:

"Using the CLAUDE.md context, create the four service classes: SubscriberService, NewsletterIssueService, CampaignService, and EmailService. Place them in the service/ package. Follow all coding standards from CLAUDE.md."

Done when:

  • All four service files exist in src/main/kotlin/com/condado/newsletter/service/.
  • ./gradlew build compiles cleanly.

Step 5 — REST Controllers & DTOs

Goal: Expose the service layer as a REST API with proper request/response DTOs.

Controllers to create:

Controller Base Path Methods
SubscriberController /api/v1/subscribers POST, GET, DELETE /{email}
NewsletterIssueController /api/v1/newsletter-issues POST, GET, PUT /{id}, DELETE /{id}
CampaignController /api/v1/campaigns POST, GET, POST /{id}/trigger, DELETE /{id}

DTOs to create (in dto/ package):

  • SubscriberCreateDto, SubscriberResponseDto
  • NewsletterIssueCreateDto, NewsletterIssueUpdateDto, NewsletterIssueResponseDto
  • CampaignCreateDto, CampaignResponseDto

Prompt to use with AI:

"Using the CLAUDE.md context, create the REST controllers and all DTOs. Controllers go in controller/, DTOs in dto/. All controllers must return ResponseEntity<T>. DTOs must use validation annotations."

Done when:

  • All controller and DTO files exist.
  • Swagger UI at http://localhost:8080/swagger-ui.html shows all endpoints.
  • Manual test with a REST client (curl / Postman / Swagger UI) succeeds for basic CRUD.

Step 6 — Email Sending (Spring Mail + Thymeleaf)

Goal: Send real HTML emails using a Thymeleaf template.

What to create:

  • src/main/resources/templates/newsletter-email.html — the HTML email template
  • EmailService (already defined in Step 4) — connect it to the template engine

Template variables:

  • ${subscriber.name} — recipient's name
  • ${issue.title} — newsletter title
  • ${issue.htmlBody} — main newsletter content
  • ${unsubscribeUrl} — one-click unsubscribe link

Prompt to use with AI:

"Using the CLAUDE.md context, create the Thymeleaf HTML email template and complete the EmailService to render it and send via JavaMailSender. Include an unsubscribe link in the template."

Done when:

  • Template file exists at src/main/resources/templates/newsletter-email.html.
  • EmailService.sendNewsletterEmail() renders the template and sends via SMTP.
  • Tested with a real SMTP server (e.g., Mailtrap or Gmail SMTP in dev).

Step 7 — Scheduler (Automated Campaigns)

Goal: Automatically trigger campaigns at their scheduled_at time.

What to create:

  • NewsletterScheduler class in scheduler/ package
  • Uses @Scheduled(fixedDelay = 60000) — checks every 60 seconds
  • Finds all SCHEDULED campaigns where scheduled_at <= now()
  • Calls CampaignService.triggerCampaign() for each
  • Updates SendLog entries for every subscriber

Prompt to use with AI:

"Using the CLAUDE.md context, create the NewsletterScheduler class. It should run every 60 seconds, find campaigns due to be sent, and dispatch emails to all active subscribers. Update SendLog with SENT or FAILED status for each attempt."

Done when:

  • NewsletterScheduler.kt exists in scheduler/ package.
  • Scheduler correctly processes due campaigns.
  • SendLog records are created for each send attempt.
  • @EnableScheduling is added to the main application class or a config class.

Step 8 — Security (API Key Authentication)

Goal: Protect the API with a simple API key header check.

Approach:

  • Use Spring Security with a custom OncePerRequestFilter
  • Clients must pass X-API-KEY: <key> header
  • The key is stored in an environment variable API_KEY
  • Public endpoints (unsubscribe link) are excluded from auth

Prompt to use with AI:

"Using the CLAUDE.md context, add API key authentication using Spring Security. Create a custom filter that checks the X-API-KEY header. The key must come from the API_KEY environment variable. Exclude GET /api/v1/subscribers/unsubscribe/** from auth."

Done when:

  • All API endpoints return 401 Unauthorized without a valid API key.
  • Unsubscribe endpoint works without auth.
  • API key is read from environment variable, never hardcoded.

Step 9 — Unit & Integration Tests

Goal: Test every service class and at least one integration test per controller.

Tests to create:

Test class Type Covers
SubscriberServiceTest Unit subscribe, unsubscribe, list
NewsletterIssueServiceTest Unit create, update, archive
CampaignServiceTest Unit schedule, cancel, trigger
EmailServiceTest Unit template rendering, send logic
SubscriberControllerTest Integration POST /api/v1/subscribers
NewsletterIssueControllerTest Integration CRUD /api/v1/newsletter-issues
CampaignControllerTest Integration POST, trigger, cancel

Prompt to use with AI:

"Using the CLAUDE.md context, generate unit tests for all service classes using MockK, and integration tests for all controllers using @SpringBootTest with H2 in-memory database. Follow the naming convention should_[expectedBehavior]_when_[condition]."

Done when:

  • ./gradlew test passes with all tests green.
  • Code coverage for service classes is ≥ 80%.

Step 10 — Docker & Deployment Config

Goal: Containerize the application and provide a docker-compose.yml for local development.

What to create:

condado-news-letter/
├── Dockerfile
├── docker-compose.yml          # App + PostgreSQL + Mailhog (dev SMTP)
└── docker-compose.prod.yml     # App + PostgreSQL only

Prompt to use with AI:

"Using the CLAUDE.md context, create a multi-stage Dockerfile for the Spring Boot app, a docker-compose.yml for local development (includes PostgreSQL and Mailhog for email testing), and a docker-compose.prod.yml for production (PostgreSQL only, env vars from host)."

Done when:

  • docker-compose up starts the full stack.
  • App connects to the PostgreSQL container.
  • Emails sent in dev are captured by Mailhog at http://localhost:8025.
  • ./gradlew build && docker build -t condado-newsletter . succeeds.

Notes & Decisions Log

Use this section to record important decisions made during the build. Add entries as you go.

Date Decision Reason
2026-03-26 Chose Kotlin + Spring Boot 3.x Modern, type-safe, great Spring support
2026-03-26 MockK over Mockito More idiomatic for Kotlin
2026-03-26 UUID as primary keys Better for distributed systems
2026-03-26 Thymeleaf for email templates Native Spring Boot support
2026-03-26 API key auth (not JWT) for simplicity in step 1 Can be upgraded to OAuth2 later