From 4fce3f395e09b5a594edda1b8bf469b0f24bdb26 Mon Sep 17 00:00:00 2001 From: Isabella Lam Date: Mon, 17 Aug 2026 16:41:08 -0400 Subject: [PATCH 1/2] create email async plans Plan and implement asynchronous email backend and UI. Swap to database-backed templates. Fix backend and frontent tests Remove MatchingSendPage and dedupe sending --- db/migration/V0007__Create_email_tables.sql | 75 +++++++ docs/email-async/00-overview.md | 107 +++++++++ docs/email-async/01-async-send-pipeline.md | 207 ++++++++++++++++++ .../02-progress-history-resend-apis.md | 76 +++++++ docs/email-async/03-async-admin-frontend.md | 64 ++++++ docs/email-async/04-matching-send-flow.md | 54 +++++ docs/email-async/05-template-management.md | 64 ++++++ js/src/features/emails/api/emailAPI.ts | 131 ++++++++++- js/src/features/emails/api/parseCSV.ts | 28 +-- js/src/features/emails/dto/emailDto.ts | 68 ++++++ 10 files changed, 858 insertions(+), 16 deletions(-) create mode 100644 db/migration/V0007__Create_email_tables.sql create mode 100644 docs/email-async/00-overview.md create mode 100644 docs/email-async/01-async-send-pipeline.md create mode 100644 docs/email-async/02-progress-history-resend-apis.md create mode 100644 docs/email-async/03-async-admin-frontend.md create mode 100644 docs/email-async/04-matching-send-flow.md create mode 100644 docs/email-async/05-template-management.md diff --git a/db/migration/V0007__Create_email_tables.sql b/db/migration/V0007__Create_email_tables.sql new file mode 100644 index 0000000..53145be --- /dev/null +++ b/db/migration/V0007__Create_email_tables.sql @@ -0,0 +1,75 @@ +CREATE TABLE IF NOT EXISTS "email_templates" ( + id UUID PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + subject TEXT NOT NULL, -- [] template + body TEXT NOT NULL, -- [] template + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS "email_requests" ( + id UUID PRIMARY KEY, -- requestId + label TEXT, + sender_email TEXT, + source TEXT NOT NULL, -- 'MANUAL' | 'MATCHING' + template_id UUID, + total_count INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_request_template FOREIGN KEY (template_id) REFERENCES email_templates(id) +); + +CREATE TABLE IF NOT EXISTS "emails" ( + id UUID PRIMARY KEY, -- emailId + request_id UUID NOT NULL, + matches_id UUID, -- nullable; future FK to matches + recipient_1 TEXT NOT NULL, -- per1 (always present) + recipient_2 TEXT, -- per2; NULL for a solo email, set for a pair + reply_to TEXT, + template_id UUID NOT NULL, -- load-bearing: runner renders subject/body from this + template_values JSONB NOT NULL, -- variables merged into the template at send-time + status TEXT NOT NULL DEFAULT 'PENDING', -- PENDING | PROCESSING | SENT | ERROR + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + sent_at TIMESTAMPTZ, + CONSTRAINT fk_email_request FOREIGN KEY (request_id) REFERENCES email_requests(id), + CONSTRAINT fk_email_template FOREIGN KEY (template_id) REFERENCES email_templates(id) +); + +CREATE INDEX IF NOT EXISTS idx_emails_status_created ON emails (status, created_at); -- claim query +CREATE INDEX IF NOT EXISTS idx_emails_request ON emails (request_id); -- progress (Inc 2) +CREATE INDEX IF NOT EXISTS idx_emails_matches ON emails (matches_id); -- dedup (Inc 4) +CREATE INDEX IF NOT EXISTS idx_emails_recipient_1 ON emails (recipient_1); +CREATE INDEX IF NOT EXISTS idx_emails_recipient_2 ON emails (recipient_2) WHERE recipient_2 IS NOT NULL; -- partial + +-- Seed read-only templates (create/list/delete arrives in Increment 5). +INSERT INTO "email_templates" (id, name, subject, body) VALUES + ( + '00000000-0000-0000-0000-000000000001', + 'Pair', + replace( + '[PatChats $${month}] $${per1.firstName} and $${per2.firstName}, you''ve been paired for PatChats!', + '$${', + '$' || '{' + ), + replace( + E'Hey $${per1.firstName} and $${per2.firstName}! \n\nWe''ve paired you two for PatChats this month! Find some time to have a 30 minute coffee chat or video call with your pairing!\nShare a screenshot or selfie of you two in the #pat-chats channel on the Discord server! \n\n$${per1.name} ($${per1.email}): \n$${per1.intro: {Intro missing! Send me an intro to add to the emails.}} \n$${per1.linkedin:}\n\n$${per2.name} ($${per2.email}): \n$${per2.intro: {Intro missing! Send me an intro to add to the emails.}}\n$${per2.linkedin:}\n\nLet me know if you''d like to update your pairing information or want to be taken off the list.\n \nCheers,\nPatina Network', + '$${', + '$' || '{' + ) + ), + ( + '00000000-0000-0000-0000-000000000002', + 'Reminder', + replace( + '[PatChats $${month}] Reminder: Have you had your PatChat yet?', + '$${', + '$' || '{' + ), + replace( + E'Hi $${per1.firstName} and $${per2.firstName},\n\nJust a friendly reminder that you were paired for PatChats this month!\nIf you haven''t had your 30 minute coffee chat or video call yet, now''s a great time to schedule it.\nDon''t forget to share a screenshot or selfie in the #pat-chats channel on the Discord server!\n\n$${per1.name} ($${per1.email})\n$${per2.name} ($${per2.email})\n\nLet me know if you''d like to update your pairing information or be taken off the list.\n\nCheers,\nPatina Network', + '$${', + '$' || '{' + ) + ) +ON CONFLICT (id) DO NOTHING; diff --git a/docs/email-async/00-overview.md b/docs/email-async/00-overview.md new file mode 100644 index 0000000..e7fe2ec --- /dev/null +++ b/docs/email-async/00-overview.md @@ -0,0 +1,107 @@ +# Async Email Service — Overview + +This directory specifies the conversion of the `email` domain from a **synchronous, in-memory** sender +into an **asynchronous transactional-outbox pipeline** with a background runner, a live progress UI, a +history view, and DB-stored templates. + +The work is split into **5 sequential vertical-slice increments**, each in its own file. Every increment +is independently shippable and demoable, and **no increment depends on a later one**. Read this overview +first; each increment file is self-contained for implementation and links back here for rationale. + +| File | Increment | Delivers | Prerequisites | +|------|-----------|----------|---------------| +| [01-async-send-pipeline.md](01-async-send-pipeline.md) | Async send pipeline (backend core) | Async sending works via API | — | +| [02-progress-history-resend-apis.md](02-progress-history-resend-apis.md) | Progress, history & resend APIs | Batch status queryable + manual resend | Inc 1 | +| [03-async-admin-frontend.md](03-async-admin-frontend.md) | Async admin frontend | User-facing manual send + live progress + history | Inc 1, 2 | +| [04-matching-send-flow.md](04-matching-send-flow.md) | Matching send flow | Pairing notifications end-to-end | Inc 1–3 | +| [05-template-management.md](05-template-management.md) | Template management (create/list/delete + UI) | Self-service template create + delete (no code) | Inc 1 | + +--- + +## Context (why this change) + +Today `POST /api/email/send` renders caller-supplied templates and sends each message over SMTP inside a +**request-blocking `for` loop** ([EmailService.java:29](../../src/main/java/org/patinanetwork/patchats/email/EmailService.java)), +returning per-message results. There is **no persistence** for email and **no DAO layer anywhere** in the +codebase. Consequences: the HTTP request blocks for the whole batch, there is no durable record or live +progress, and a crash mid-batch loses everything. + +Target design: +- A request **enqueues** rows into Postgres and returns immediately (`202`). +- A single **on-demand runner** (started by a manual/frontend API kick, plus a startup drain) drains the `emails` table, **renders each row from its template**, sends over SMTP, and updates each row's status. +- A **new admin UI** polls the backend for live per-email progress and a history of past sending sessions. +- Templates live in a DB table (**seeded read-only** first, **create/list/delete** in Increment 5; immutable — no edit). +- Recipient/pair data comes from **CSV uploads for now**, behind a swappable port, with a future migration + to another team's DB. + +**Stack:** Spring Boot, Postgres + Flyway, `spring-boot-starter-jdbc` with the fluent **`JdbcClient`** +(Boot 3.2+; **no JPA**), React + Mantine frontend. + +--- + +## Key decisions & tradeoffs (cross-cutting) + +Each increment repeats only the rows it needs; this is the full reference. + +| # | Decision | Choice | Why / tradeoff accepted | +|---|----------|--------|--------------------------| +| 1 | Queue transport | **DB-as-queue (outbox), no SQS** | Postgres is already the transactional store; single atomic insert, no dual-write. Any future retry logic would be ours (SQS gives it free) — fine since retry is deferred. | +| 2 | `/send` semantics | **New async endpoint, `202` + `{requestId, accepted}`** | Added alongside the existing sync `/send` (kept during migration, retired later). | +| 3 | Row granularity | **One row per message** (1–2 recipients), grouped by `requestId` | Matches current domain; `matchesId` stays 1:1 with a row. | +| 4 | Render timing | **Render at send-time** — the runner renders each row from `template_id` + `template_values` just before sending | Stores only the template ref + variables, not rendered text → smaller rows. Tradeoffs: render errors surface **async** as `ERROR` rows (no `400`); the runner needs the renderer + template lookup; `/preview` must share a render helper with the runner to avoid drift. (Templates are **immutable** — see #15 — so a queued row's template never changes under it.) | +| 5 | Deployment topology | **Strictly single instance** | Simplest claim logic. ⚠️ Deploys must be **stop-then-start** to avoid a transient 2-runner window. | +| 6 | Runner driver | **On-demand executor** (manual/frontend kick → drain loop → idle), **no polling** | Zero steady-state cost for a monthly workload. Coverage from an explicit `POST /api/email/process` kick (issued by the frontend after a send / by ops) plus a startup drain — **no enqueue-time auto-trigger**, no always-on poller. Tradeoff: if the kick is never issued, the batch waits for the next kick or a restart. | +| 7 | Intra-drain processing | **Sequential small batch** (claim ≤50 oldest, send one-at-a-time) | Gentle on SMTP, per-row error handling. Parallel pool is a future upgrade. | +| 8 | Retry policy | **No auto-retry — one attempt → `ERROR`** (deferred) | Avoids re-sending to the same person. Failed rows wait for a deliberate manual resend. | +| 9 | Crash recovery | **On boot: orphaned `PROCESSING` → `ERROR`** (at-most-once) | Guarantees **zero duplicate emails**. Cost: an email that crashed pre-send is stranded as `ERROR`, needs manual resend. | +| 10 | Progress UI | **Per-batch summary + per-email table**, polled live; **history tab** | Poll self-terminates when the batch is terminal. | +| 11 | Session model | **Parent `email_requests` table** | Durable session record; stable count denominator; home for the `source` flag. | +| 12 | Matching | **In scope** (Increment 4) | A **second producer** into the same queue: the manual flow (`source=MANUAL`) is the first writer into the `emails` outbox; matching (`source=MATCHING`) is a second endpoint that fans pairs into messages and calls the **same `EmailEnqueueService.enqueue(...)`**. Reuses the existing queue, runner, tables, and progress UI unchanged — only a new producer endpoint is added; the `source` column distinguishes them. | +| 13 | Match selection | **Explicit selection** (browsable by cycle), interim rows from the **pairings CSV** | DB-read is the future swap. | +| 14 | Pairing email shape | **One email to both partners** (per1/per2, 2 recipients) | Reuses multi-recipient sender; `matchesId` 1:1. | +| 15 | Templates | **DB-stored; seeded read-only (Inc 1) → create / list / delete (Inc 5)**; **immutable — no edit** | Admins add/select/delete without code once Inc 5 lands; to change copy, create a new template. Immutability keeps render-at-send safe — a queued row's template never changes under it. | +| 16 | Template model | **All sends via a selected `templateId`** (`template_id` is load-bearing / `NOT NULL`) | "Add a template" is the escape hatch. Future freeform sends would need rendered-body columns back (a hybrid), since freeform has no template to render at send-time. | +| 17 | Recipient/pair source | **CSV now, behind a swappable `RecipientSource` port**; DB later | Unblocks both flows without the unready DB; future swap is one seam. | +| 18 | Recipient storage | **Two scalar columns** `recipient_1` / `recipient_2` (nullable) | Plain btree indexing + `=`/`LIKE`; maps to per1/per2 (capped at 2). | +| 19 | Persistence API | **`JdbcClient`** (not `JdbcTemplate`) | Fluent, auto-configured; drop to `JdbcTemplate` only for batch inserts. | + +--- + +## Data model (full reference) + +The migration lands in **Increment 1** ([details](01-async-send-pipeline.md#1a-data-model)); all three +tables are created together because of the FKs. Summary: + +- **`email_templates`** — reusable `${}` subject/body templates. Seeded read-only in Inc 1; CRUD in Inc 5. +- **`email_requests`** — one row per "sending session" (the history-tab unit); carries `source` + (`MANUAL`/`MATCHING`), `template_id`, `total_count`, `created_at`. +- **`emails`** — one row per message (the outbox): `recipient_1`/`recipient_2`, `reply_to`, `template_id` + + `template_values` (the runner renders `subject`/`body` from these at send-time — **rendered text is not stored**), + `status` (`PENDING`|`PROCESSING`|`SENT`|`ERROR`), `error_message`, timestamps. + +--- + +## Deferred (documented, not in these increments) + +- **DB-backed recipient/pair source** — swap the CSV `RecipientSource` impl for the other team's DB. +- **Auto-retry with backoff** — re-add `attempt_count` / `next_attempt_at`, a claim eligibility clause, and a + one-shot `TaskScheduler` re-arm. Intentionally omitted now to avoid any risk of re-sending to the same person. +- **SQS transport** — a future scale lever if volume outgrows DB-as-queue. +- **Multi-instance runner** — `SELECT … FOR UPDATE SKIP LOCKED` or ShedLock leader election. +- **Concurrent send pool** — a bounded, rate-limit-capped executor over the claimed batch. +- **Global ops dashboard** — an always-on monitor across all sends (Inc 3 ships per-batch + history only). +- **Freeform (non-template) sends** — would require adding rendered `subject`/`body` columns back (a hybrid with + the render-at-send rows), since a freeform email has no template to render at send-time. +- **Storing sent output for audit** — render-at-send does not keep the exact bytes that went out; if a template is + later edited/deleted, past sends can't be reconstructed. Add rendered columns (or a sent-copy table) if audit needs it. + +--- + +## Cross-cutting notes for implementers + +- **External dependency:** the other team's user/pair DB. Isolated behind `RecipientSource` + nullable + `matches_id`; the CSV→DB swap touches only the source impl. +- **Ops:** production deploys must be **stop-then-start** (single-instance runner assumption). +- **Reuse, don't reinvent:** the SMTP port [EmailSender](../../src/main/java/org/patinanetwork/patchats/email/EmailSender.java), + the [TemplateRenderer](../../src/main/java/org/patinanetwork/patchats/email/TemplateRenderer.java), and + `EmailService.mergeVariables` already exist — the pipeline wraps them, it does not replace them. diff --git a/docs/email-async/01-async-send-pipeline.md b/docs/email-async/01-async-send-pipeline.md new file mode 100644 index 0000000..f37a605 --- /dev/null +++ b/docs/email-async/01-async-send-pipeline.md @@ -0,0 +1,207 @@ +# Increment 1 — Async send pipeline (backend core) + +**Prerequisites:** none (greenfield). **Delivers:** enqueue an async send and have a background runner +actually deliver it — fully functional and testable via API + dev-profile logging, **no UI yet**. +See [00-overview.md](00-overview.md) for full context and the decision table. + +## Decisions that apply here +- **DB-as-queue (outbox), no SQS** (#1) — the `emails` table *is* the queue. +- **Render at send-time** (#4) — store `template_id` + `template_values`; the runner renders `subject`/`body` per + row just before sending (rendered text is **not** stored). +- **Single instance** (#5) — no row-locking needed; deploys must be **stop-then-start**. +- **On-demand runner, no polling** (#6) — started only by an explicit kick (`POST /api/email/process`, called by + the frontend after a send / by ops) and by a startup drain. **No auto-trigger on enqueue.** +- **Sequential small batch** (#7) — claim ≤50, send one-at-a-time. +- **No auto-retry** (#8) — a failed send → terminal `ERROR`. +- **At-most-once crash recovery** (#9) — orphaned `PROCESSING` → `ERROR` on boot. +- **All sends via a `templateId`** (#16); templates **seeded read-only** here (#15). +- **`JdbcClient`, not `JdbcTemplate`** (#19). **Two scalar recipient columns** (#18). + +--- + +## 1a. Data model — `db/migration/V0004__Create_email_tables.sql` + +All three tables are created here (the FKs require it). `email_templates` is **seeded and read-only** until +Increment 5. Follow the existing style in [db/migration/](../../db/migration/) (`UUID` PKs, `TIMESTAMPTZ`, +named FK constraints). + +```sql +CREATE TABLE IF NOT EXISTS "email_templates" ( + id UUID PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + subject TEXT NOT NULL, -- ${} template + body TEXT NOT NULL, -- ${} template + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS "email_requests" ( + id UUID PRIMARY KEY, -- requestId + label TEXT, + sender_email TEXT, + source TEXT NOT NULL, -- 'MANUAL' | 'MATCHING' + template_id UUID, + total_count INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_request_template FOREIGN KEY (template_id) REFERENCES email_templates(id) +); + +CREATE TABLE IF NOT EXISTS "emails" ( + id UUID PRIMARY KEY, -- emailId + request_id UUID NOT NULL, + matches_id UUID, -- nullable; future FK to matches + recipient_1 TEXT NOT NULL, -- per1 (always present) + recipient_2 TEXT, -- per2; NULL for a solo email, set for a pair + reply_to TEXT, + template_id UUID NOT NULL, -- load-bearing: runner renders subject/body from this + template_values JSONB NOT NULL, -- variables merged into the template at send-time + status TEXT NOT NULL DEFAULT 'PENDING', -- PENDING | PROCESSING | SENT | ERROR + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + sent_at TIMESTAMPTZ, + CONSTRAINT fk_email_request FOREIGN KEY (request_id) REFERENCES email_requests(id), + CONSTRAINT fk_email_template FOREIGN KEY (template_id) REFERENCES email_templates(id) +); + +CREATE INDEX IF NOT EXISTS idx_emails_status_created ON emails (status, created_at); -- claim query +CREATE INDEX IF NOT EXISTS idx_emails_request ON emails (request_id); -- progress (Inc 2) +CREATE INDEX IF NOT EXISTS idx_emails_matches ON emails (matches_id); -- dedup (Inc 4) +CREATE INDEX IF NOT EXISTS idx_emails_recipient_1 ON emails (recipient_1); +CREATE INDEX IF NOT EXISTS idx_emails_recipient_2 ON emails (recipient_2) WHERE recipient_2 IS NOT NULL; -- partial +``` + +Also **seed ≥1 template** in the migration (include a pairing template for Increment 4). +Recipient search later: `WHERE recipient_1 = :x OR recipient_2 = :x`; solo emails: `recipient_2 IS NULL`. + +## 1b. Persistence (net-new — first DAOs in the repo) + +Use Spring's fluent **`JdbcClient`** (auto-configured; inject directly — do **not** use `JdbcTemplate` +except where noted). Create `@Repository` classes: + +- `EmailRepo` — insert children, claim batch, update status per row, boot reset. +- `EmailRequestRepo` — insert the parent session row. +- `EmailTemplateRepo` — **read/list only** here (`findById`, `findAll`); writes arrive in Increment 5. + +Pattern: +```java +jdbcClient.sql("SELECT * FROM email_templates WHERE id = :id") + .param("id", id) + .query(new EmailTemplateRowMapper()) + .optional(); +``` +Map `JSONB` (`template_values`) ↔ Java via a small Jackson helper. For the **N-child batch insert**, drop to +the underlying `JdbcTemplate.batchUpdate(...)` (JdbcClient has no batch API yet) — inject `JdbcTemplate` +only in `EmailRepo` for that one method. + +## 1c. Enqueue path (producer) + +- **DTO** `EnqueueEmailRequest { UUID templateId, String source, String replyTo?, List messages }`, + where each `Message` carries recipients + variable maps (built by the frontend from CSV; later the DB). + Reuse the shape of the existing + [SendEmailRequest.Message/Recipient](../../src/main/java/org/patinanetwork/patchats/email/dto/SendEmailRequest.java). +- **`EmailEnqueueService.enqueue(request)`** — in **one `@Transactional` method**: + 1. Validate `templateId` exists (`EmailTemplateRepo.findById`; `400` if unknown). **Do not render here.** + 2. Per message: build the variable map with + [`EmailService.mergeVariables`](../../src/main/java/org/patinanetwork/patchats/email/EmailService.java:83) + (no rendering). + 3. Insert one `email_requests` row (`total_count = messages.size()`) + N `emails` rows (`status='PENDING'`, + `recipient_1`/`recipient_2` from the message, `template_id`, `template_values` = the merged map). Rendering + happens later, in the runner (1e). + - **The service does not start the runner.** After the `202` returns (transaction committed), the **caller** + kicks the drain via `POST /api/email/process` (see below). This is the "manual/frontend kick only" model (#6). +- *(Optional)* dry-run render at enqueue for **early validation only** — reject a template that can't render up + front. Only the *values* are stored, never the output. Skip it for the minimal path; otherwise render errors + surface asynchronously as `ERROR` rows. +- **Endpoints** (new `EmailAsyncController` or extend the existing controller): + - `POST /api/email/send/async` → `enqueue(...)` with `source=MANUAL` → **`202 Accepted`** `{ requestId, accepted }`. + - `POST /api/email/process` → `EmailDrainer.trigger()`. **This is the primary way sending starts** — the + frontend calls it right after a `202` (the enqueue tx has committed by then, so there is no visibility race), + and ops can call it manually. Returns `202`/`200` immediately (the drain runs on the executor thread). + - `GET /api/email/templates` → read-only list (so seeded templates are usable + verifiable now). +- **Update `/preview`** in + [EmailController](../../src/main/java/org/patinanetwork/patchats/email/EmailController.java) to accept a + `templateId`, load the template + render via a **shared render helper that the runner (1e) also calls** — so + preview matches what the runner will actually send. (This shared helper is the guard against preview/runner + drift; do not duplicate render logic.) Keep the existing sync `/send` untouched for now. + +## 1d. Recipient/pair source port + +Define `interface RecipientSource` and a CSV-backed implementation for v1. In practice the frontend already +parses CSV ([parseCSV.ts](../../js/src/features/emails/api/parseCSV.ts)) and posts structured messages, so the +"port" is mostly the request-DTO shape plus a clear seam; the point is that a future `DbRecipientSource` +(reading `members`/`matches`) is a **one-file swap**. Keep `matches_id` optional until then. + +## 1e. Runner (`EmailDrainer`) — on-demand kick, no polling + +- **Bean:** a single-thread `ThreadPoolTaskExecutor` named `emailDrainExecutor` (core=max=1 so drains + serialize and overlapping triggers coalesce), configured with `setWaitForTasksToCompleteOnShutdown(true)` + + an await timeout. `@EnableAsync` is already present on + [PatChatsApplication](../../src/main/java/org/patinanetwork/patchats/PatChatsApplication.java); **no** + `@EnableScheduling` / `TaskScheduler` is needed (there are no timed retries). +- **`EmailDrainer.trigger()`** submits a drain job to `emailDrainExecutor` **only if one isn't already + running** — guard with an `AtomicBoolean` via `compareAndSet`; if `trigger()` fires while a drain is + running, set a `rerun` flag so the current drain loops again instead of exiting. +- **Triggers (the only things that start a runner):** + 1. **Explicit kick** — `POST /api/email/process` calls `trigger()`. The frontend issues it right after a send's + `202` (and after a resend); ops can call it manually. Because it happens after the request's transaction has + committed, the rows are already visible — no `AFTER_COMMIT` event is needed. **There is no automatic + enqueue-time trigger** (the accepted tradeoff of #6: if the kick is never issued, the batch waits for the next + kick or a restart). + 2. **On startup** — `@EventListener(ApplicationReadyEvent.class)` first resets `PROCESSING → ERROR` + (at-most-once recovery), then calls `trigger()` once (covers rows left `PENDING` before shutdown). This is the + only safety net for a missed kick. +- **Drain job** (runs on the executor thread, loops until no rows, then the thread idles): + 1. **Claim** up to 50 `PENDING` rows atomically: + ```sql + UPDATE emails SET status='PROCESSING', updated_at=now() + WHERE id IN (SELECT id FROM emails WHERE status='PENDING' ORDER BY created_at LIMIT 50) + RETURNING *; + ``` + 2. For each claimed row **sequentially**: load its template (`template_id`) and **render** `subject`/`body` + from `template_values` via the shared render helper (the same one `/preview` uses — + [`TemplateRenderer`](../../src/main/java/org/patinanetwork/patchats/email/TemplateRenderer.java)); then build + `OutgoingEmail([recipient_1(, recipient_2)], subject, body, replyTo)` (drop a null `recipient_2`) and call + [`EmailSender.send`](../../src/main/java/org/patinanetwork/patchats/email/EmailSender.java). On success → + `status='SENT'`, `sent_at=now()` (**commit per row** — keeps any duplicate window to ≤1 email). On failure — + including a **render failure** (template edited into an invalid state, missing variable) — → `status='ERROR'`, + `error_message=ex.getMessage()` (**no retry**). *(Cache templates per drain to avoid reloading the same one + for every row in a batch.)* + 3. Re-claim; when a claim returns 0 rows, stop (honor the `rerun` flag if set). + +**Runner tradeoffs:** on-demand kick (manual/frontend API request) + single-instance + sequential is chosen +for zero idle cost. Rejected: `@Scheduled` (always-on timer for a monthly job), an **AFTER_COMMIT +enqueue-time auto-trigger** (couples sending to the enqueue transaction and needs an extra event class — the +explicit kick keeps the frontend in control and the rows are already committed by the time it fires), raw +`Thread` (reimplements lifecycle), SQS (extra infra, dual source of truth). Accepted tradeoff: if the kick is +never issued, the batch waits for the next kick or a restart (the startup drain is the safety net). +Future upgrades don't disturb the claim logic: a concurrent rate-limited pool for throughput; `SKIP LOCKED` +or ShedLock for multi-instance. + +--- + +## Files to touch +- **Create:** `db/migration/V0004__Create_email_tables.sql`; `email/` — `EmailRepo`, + `EmailRequestRepo`, `EmailTemplateRepo`, row mappers, a Jackson JSONB helper; + `EmailEnqueueService`, `dto/EnqueueEmailRequest`, `dto/EnqueueEmailResponse`; `RecipientSource` (+ CSV impl); + a shared render helper (wrapping `TemplateRenderer`, used by both `/preview` and the runner); + `EmailDrainer` (depends on `EmailTemplateRepo` + the render helper + `EmailSender`), + an executor config `@Configuration`. +- **Modify:** [EmailController](../../src/main/java/org/patinanetwork/patchats/email/EmailController.java) + (add `/send/async`, `/templates` list, update `/preview`). + +## Verification +- **Unit** (fake `EmailSender`, like + [EmailServiceTest](../../src/test/java/org/patinanetwork/patchats/email/EmailServiceTest.java)): + - `EmailEnqueueServiceTest` — enqueue stores `template_id` + `template_values` (no rendered output); an unknown + `templateId` → `400`; one parent + N children inserted in a single transaction. + - `EmailDrainerTest` — claims ≤50; **renders each row from its template** then `SENT` on success; a **send or + render** failure → terminal `ERROR` with **no** re-attempt; boot listener resets `PROCESSING → ERROR`; + overlapping `trigger()` calls coalesce to one drain. +- **Repository/integration** — Testcontainers or local Postgres ([db/README.md](../../db/README.md)) to run + the `V0004` migration and exercise the claim `UPDATE … RETURNING`. +- **End-to-end** — with the dev profile (logs instead of sending — + [LoggingEmailSender](../../src/main/java/org/patinanetwork/patchats/email/LoggingEmailSender.java)): + `just dev`, `POST /api/email/send/async`, confirm `202 {requestId}` and rows move `PENDING→PROCESSING→SENT` + in the logs; force a send failure to confirm straight-to-`ERROR`; restart mid-batch to confirm the boot + reset takes `PROCESSING→ERROR`. diff --git a/docs/email-async/02-progress-history-resend-apis.md b/docs/email-async/02-progress-history-resend-apis.md new file mode 100644 index 0000000..75138e3 --- /dev/null +++ b/docs/email-async/02-progress-history-resend-apis.md @@ -0,0 +1,76 @@ +# Increment 2 — Progress, history & resend APIs + +**Prerequisites:** [Increment 1](01-async-send-pipeline.md) (tables, rows, `EmailDrainer`). +**Delivers:** batch state queryable via API + a manual resend path. This is a read/UX-support layer over +Increment 1 — no schema changes. See [00-overview.md](00-overview.md) for full context. + +## Decisions that apply here +- **Progress = per-batch summary + per-email list** (#10), scoped by `requestId`. +- **Session model = parent `email_requests` table** (#11) — the history unit. +- **At-most-once** (#9) means `ERROR` rows may include never-sent emails, so a **manual resend** is required. + +--- + +## Endpoints + +Add to the email controller; back them with the Increment-1 repositories (add query methods as needed). + +### `GET /api/email/progress?requestId={uuid}` +Returns the live state of one batch. One `GROUP BY status` for the counts plus the row list. +```jsonc +{ + "total": 12, + "pending": 3, + "processing": 1, + "sent": 7, + "error": 1, + "emails": [ + { "id": "…", "recipients": ["a@x.com", "b@x.com"], "status": "SENT", "error": null, "sentAt": "…" }, + { "id": "…", "recipients": ["c@x.com"], "status": "ERROR", "error": "…", "sentAt": null } + ] +} +``` +`recipients` is derived from `recipient_1` (+ `recipient_2` when non-null). Counts query: +```sql +SELECT status, count(*) FROM emails WHERE request_id = :requestId GROUP BY status; +``` + +### `GET /api/email/requests` +History list for the sessions tab — one entry per `email_requests` row with aggregated child counts, +newest first. +```sql +SELECT r.id, r.source, r.template_id, r.created_at, r.total_count, + count(*) FILTER (WHERE e.status = 'SENT') AS sent, + count(*) FILTER (WHERE e.status = 'ERROR') AS error, + count(*) FILTER (WHERE e.status IN ('PENDING','PROCESSING')) AS in_flight + FROM email_requests r + JOIN emails e ON e.request_id = r.id + GROUP BY r.id + ORDER BY r.created_at DESC; +``` +Return `terminal = (in_flight == 0)` so the frontend knows whether a past session needs polling. +(Consider pagination later; not required for v1 volume.) + +### `POST /api/email/{emailId}/resend` +The manual recovery the at-most-once model requires. Flip the row `ERROR → PENDING` (clear `error_message`, +`updated_at = now()`), then call `EmailDrainer.trigger()` so it sends promptly. Reject if the row is not +currently `ERROR` (`409`/`400`). + +### `POST /api/email/process` *(optional)* +A convenience "process now" kick that just calls `EmailDrainer.trigger()`. Handy for ops; not required by +the UI. + +--- + +## Files to touch +- **Modify:** the email controller (add the three/four endpoints); Increment-1 repositories (add + `countByStatus(requestId)`, `findEmailsByRequest(requestId)`, `listRequestsWithCounts()`, + `markPending(emailId)` query methods). +- **Create:** `dto/EmailProgressResponse`, `dto/EmailRequestSummary`. + +## Verification +- **Controller/repository tests:** the aggregate counts match seeded rows; the history list returns sessions + newest-first with correct counts and `terminal`; `resend` on an `ERROR` row → `PENDING`, then (with a fake + sender) drains to `SENT`; `resend` on a non-`ERROR` row is rejected. +- **Manual (Postman):** against a batch created in Increment 1, poll `GET /progress?requestId=` and watch + counts change; call `GET /requests`; force an `ERROR`, `POST /{id}/resend`, and confirm it re-sends. diff --git a/docs/email-async/03-async-admin-frontend.md b/docs/email-async/03-async-admin-frontend.md new file mode 100644 index 0000000..525406e --- /dev/null +++ b/docs/email-async/03-async-admin-frontend.md @@ -0,0 +1,64 @@ +# Increment 3 — Async admin frontend + +**Prerequisites:** [Increment 1](01-async-send-pipeline.md) (`/send/async`, `/templates`) and +[Increment 2](02-progress-history-resend-apis.md) (`/progress`, `/requests`, `/resend`). +**Delivers:** the full user-facing manual async send experience — select a template, send, watch live +progress, review history, resend failures. See [00-overview.md](00-overview.md) for full context. + +All files live under [js/src/features/emails/](../../js/src/features/emails/). + +## Decisions that apply here +- **Progress = summary + per-email table**, **polled live**, **self-terminating** (#10). +- **History tab** of past sessions (#11). +- **All sends via a selected `templateId`** (#16) — the compose UI selects a template, not freeform text. +- **CSV is the interim recipient source** (#17) — keep the uploader. + +--- + +## Send flow (rework [EmailAdminPage](../../js/src/features/emails/EmailAdminPage.tsx)) + +- **Replace** the freeform subject/body inputs with a **template selector** populated from + `GET /api/email/templates` (read-only list from Increment 1). +- **Keep** [CsvUploader](../../js/src/features/emails/_components/CsvUploader.tsx) as the interim recipient + source, and [EmailPreviewer](../../js/src/features/emails/_components/EmailPreviewer.tsx) — but preview now + renders the **selected template** against the CSV rows (the `/preview` call sends a `templateId`). +- On **Send**: `POST /api/email/send/async`, capture the returned `requestId`, and switch the page to the + **progress view** for that batch. + +## API layer (extend [emailAPI.ts](../../js/src/features/emails/api/emailAPI.ts)) +Add: `enqueueEmails(body): Promise<{requestId, accepted}>`, `getProgress(requestId)`, `listRequests()`, +`resendEmail(emailId)`, `listTemplates()`. Follow the existing fetch + `ApiResponder` unwrap pattern already +used by `sendToPreviewApi`. + +## Progress component (new — e.g. `_components/EmailProgress.tsx`) +- **Summary tiles:** total / pending / processing / sent / error (Mantine cards or a `Group` of badges). +- **Per-email table:** recipients, a status badge (color by status), error message, `sent_at`, and a + **Resend** button on `ERROR` rows (calls `resendEmail`, which re-queues + triggers a drain). +- **Polling:** every ~2s call `getProgress(requestId)` (use `@tanstack/react-query` `refetchInterval`, or a + `useEffect` + `setInterval`). **Stop polling when `pending + processing === 0`** (batch terminal). + +## History tab (new — e.g. `_components/EmailHistory.tsx`) +- One-shot `GET /api/email/requests` → a table of past sessions (created time, source, template, sent/error + counts, terminal?). +- Row click drills into that batch's per-email table — **reuse the progress table component**, but do **not** + poll a terminal batch (fetch once). + +**Polling tradeoffs:** short-interval, self-terminating polling is chosen — trivial to build, no server-push +infra, ≤2s staleness, and chatter is bounded because polling stops at terminal state. Rejected SSE (needs a +server event stream) and WebSockets (heaviest infra) as overkill for a monthly admin action. + +--- + +## Files to touch +- **Modify:** [EmailAdminPage.tsx](../../js/src/features/emails/EmailAdminPage.tsx) (template selector + + view switch), [emailAPI.ts](../../js/src/features/emails/api/emailAPI.ts) (new fns), + [emailDto.ts](../../js/src/features/emails/dto/emailDto.ts) (progress/request/template types). +- **Create:** `_components/EmailProgress.tsx`, `_components/EmailHistory.tsx`, a shared status-badge helper, + and a template-selector component. + +## Verification +- Load a users CSV, select a seeded template, preview (confirm rendered subject/body), send. +- Watch the progress table update live and **stop polling** once the batch is terminal. +- Confirm the batch appears in the **History** tab with correct counts; open it and see the per-email rows + without re-polling. +- Force an `ERROR` (dev profile) and confirm the **Resend** button re-queues and the row goes to `SENT`. diff --git a/docs/email-async/04-matching-send-flow.md b/docs/email-async/04-matching-send-flow.md new file mode 100644 index 0000000..b52f8f3 --- /dev/null +++ b/docs/email-async/04-matching-send-flow.md @@ -0,0 +1,54 @@ +# Increment 4 — Matching send flow + +**Prerequisites:** [Increment 1](01-async-send-pipeline.md) (pipeline), +[Increment 2](02-progress-history-resend-apis.md) (progress/history), and +[Increment 3](03-async-admin-frontend.md) (progress UI to reuse). +**Delivers:** pairing notifications end-to-end. This is a **second producer** into the Increment-1 queue — +the runner, tables, and progress UI are reused unchanged. See [00-overview.md](00-overview.md) for full context. + +## Decisions that apply here +- **Matching in scope** (#12); **explicit selection**, interim rows from the **pairings CSV** (#13). +- **One email to both partners** (#14) — per match: one `emails` row, 2 recipients (`per1`/`per2`). +- **Render at send-time from `templateId` + `template_values`** (#4, #16). **CSV source behind the port** (#17). + +--- + +## Backend + +- **Endpoint:** `POST /api/email/matching/send` — accepts selected pairs (interim: rows parsed from the + uploaded [pairings-test.csv](../../js/src/features/emails/examples/pairings-test.csv)) plus a `templateId`. +- **Fan-out:** per pair → build one `Message` with **two recipients** (member A = `per1`, member B = `per2`), + then call the **same `EmailEnqueueService.enqueue(...)`** from Increment 1 with `source=MATCHING`. Each pair + becomes one `emails` row addressed to both; set `matches_id` if the CSV carries a match id, else null. +- **Variable mapping:** auto-expose per-side fields as `per1.*` / `per2.*` — `name`, `email`, `bio`, + `industry`, `role`, `topics`, `linkedUrl` — from the pair's CSV columns (see the `Pair`/`User` shapes in + [emailDto.ts](../../js/src/features/emails/dto/emailDto.ts)); shared vars (e.g. `${period}`) come from the + request. The merged map is stored as `template_values` (via `EmailService.mergeVariables` at enqueue); the + runner renders it at send-time (`TemplateRenderer`). A future DB-backed source swaps only the `RecipientSource` impl. +- **Dedup guard:** before enqueuing a pair that has a `matches_id`, check for an existing non-`ERROR` row with + that `matches_id` and skip/reject it (prevents double-notifying a pair on re-run). Uses `idx_emails_matches`. + +## Frontend + +- **Match-selection UI** (new component under [js/src/features/emails/](../../js/src/features/emails/)): + browse the uploaded pairs (scoped by cycle once DB-backed later), with a checkbox per pair. +- **Show each pair's email status** (from `matches_id` lookups) so already-sent pairs are visibly + disabled/warned — the UI half of the dedup guard. +- **Select → preview → send:** reuse [EmailPreviewer](../../js/src/features/emails/_components/EmailPreviewer.tsx) + to render a `per1`/`per2` pairing email, then `POST /api/email/matching/send`, then reuse the + **Increment-3 progress view** for live status. + +--- + +## Files to touch +- **Create (backend):** a matching controller endpoint + a `MatchingSendService` (or a method on the enqueue + service) that maps pairs → messages; `dto/MatchingSendRequest`. +- **Create (frontend):** a match-selection component; add a `sendMatchingEmails(...)` fn to + [emailAPI.ts](../../js/src/features/emails/api/emailAPI.ts). +- **Reuse:** `EmailEnqueueService`, `EmailDrainer`, the progress endpoints/UI — unchanged. + +## Verification +- Upload the pairings CSV, select a seeded **pairing** template, preview one pair and confirm both partners' + variables render (`per1.*` and `per2.*`). +- Send; confirm **one email per pair addressed to both** recipients, and watch progress in the reused view. +- Re-select an already-sent pair and confirm the dedup guard blocks it (UI disabled + backend rejects). diff --git a/docs/email-async/05-template-management.md b/docs/email-async/05-template-management.md new file mode 100644 index 0000000..1818ad9 --- /dev/null +++ b/docs/email-async/05-template-management.md @@ -0,0 +1,64 @@ +# Increment 5 — Template management (create / list / delete + UI) + +**Prerequisites:** [Increment 1](01-async-send-pipeline.md) only (the `email_templates` table + +read/list DAO). **Delivers:** self-service authoring — admins **create and delete** templates with no code. +Purely **additive**; it removes the "seeded/read-only" limitation that Increments 1–4 lived with. +See [00-overview.md](00-overview.md) for full context. + +> **Templates are immutable — there is no edit/update.** To change copy, create a new template (and delete the +> old one if it's unused). This is deliberate: with render-at-send (#4), immutability means a queued row's +> template never changes under it, so there is no in-flight edit race to reason about. + +## Decisions that apply here +- **Templates DB-stored; create/list/delete lands here** (#15). +- **All sends via a selected `templateId`** (#16) — this increment makes the selectable set self-service. +- **Render at send-time** (#4): safe here because templates are immutable — a `PENDING`/`ERROR` row always renders + from the same template it was created against. The only in-flight concern is **delete** (see below). + +--- + +## Backend — template create / list / delete + +Extend `EmailTemplateRepo` (read/list already exists from Increment 1) with `insert` and `delete`, and add +endpoints: + +- `POST /api/email/templates` — create. +- `DELETE /api/email/templates/{id}` — delete. +- *(list/read already exists: `GET /api/email/templates` from Increment 1.)* + +**Validation** (reject before save, `400`): +- `name` unique and non-blank; `subject`/`body` non-blank. +- **Well-formed `${}` placeholders** — dry-run + [TemplateRenderer](../../src/main/java/org/patinanetwork/patchats/email/TemplateRenderer.java) against a set + of sample `per1.*`/`per2.*` + shared vars and reject a template that throws on malformed syntax. +- On `DELETE`: `template_id` is **load-bearing** with a `NOT NULL` FK from `emails`, so a template referenced by + any row **cannot be hard-deleted** — the FK blocks it, and deleting one referenced by `PENDING` rows would make + them unrenderable. Recommended: **block delete** if any row references it (or add a `deleted_at` **soft-delete** + flag — hidden from the selector, kept for existing rows). Never hard-delete a referenced template. + +## Frontend — `TemplateManager` (new route/tab) + +- **(a) List/table** of templates: name, `created_at`, delete action. +- **(b) Create form:** `name` + subject + body textareas, with a **live preview** that reuses `/preview` + + [EmailPreviewer](../../js/src/features/emails/_components/EmailPreviewer.tsx) so the author sees rendered + output as they type. +- **(c) Placeholder helper:** a side panel listing the available variables (`${per1.name}`, `${per1.bio}`, …, + `${per2.*}`, and shared vars like `${period}`) so authors know what they can reference. +- **(d) Delete** with a confirm dialog (disabled/blocked for referenced templates, per the delete policy). +- After this ships, the **template selectors** in Increments 3 (manual send) and 4 (matching) read this fuller, + user-managed list instead of only the seeded rows — no change needed there beyond pointing at the same + `GET /api/email/templates`. + +--- + +## Files to touch +- **Modify (backend):** `EmailTemplateRepo` (add `insert` + `delete`); the email controller (add POST + + DELETE); add validation (reuse `TemplateRenderer` for the dry run). +- **Create (frontend):** `TemplateManager` component/route; extend + [emailAPI.ts](../../js/src/features/emails/api/emailAPI.ts) with `createTemplate` and `deleteTemplate`; + template DTO types in [emailDto.ts](../../js/src/features/emails/dto/emailDto.ts). + +## Verification +- Create a template in the UI, then use it in a **manual** send (Inc 3) and a **matching** send (Inc 4). +- Delete an **unreferenced** template succeeds; deleting a **referenced** template is blocked (or soft-deletes). +- Assert validation **rejects** a template with malformed `${}` syntax and a duplicate `name`. diff --git a/js/src/features/emails/api/emailAPI.ts b/js/src/features/emails/api/emailAPI.ts index a91bc74..4476e15 100644 --- a/js/src/features/emails/api/emailAPI.ts +++ b/js/src/features/emails/api/emailAPI.ts @@ -1,6 +1,11 @@ import type { - SendRequest, + SendAsyncRequest, MessagePreview, + EnqueueEmailRequest, + EnqueueEmailResponse, + EmailProgress, + EmailRequestSummary, + EmailTemplate, } from "@/features/emails/dto/emailDto"; export async function sendToEmailApi(body: unknown) { @@ -17,8 +22,14 @@ export async function sendToEmailApi(body: unknown) { return response.json(); } +/** + * Used in main email flow to preview emails with CSV data before sending. + * @param body- The request body containing templateId, sample variables, and CSV data for the email preview. + * @returns preview of the email messages generated from the template ID with CSV data, or null if no previews are generated. + * @throws Error if the API request fails or returns a non-OK response. + */ export async function sendToPreviewApi( - body: SendRequest, + body: SendAsyncRequest, ): Promise { const response = await fetch("/api/email/preview", { method: "POST", @@ -37,3 +48,119 @@ export async function sendToPreviewApi( }; return json.payload.previews; } + +// Async email API functions + +export async function enqueueEmails( + body: EnqueueEmailRequest, +): Promise { + const response = await fetch("/api/email/send/async", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!response.ok) { + throw new Error( + `Enqueue API failed: ${response.status} ${response.statusText}`, + ); + } + const json = (await response.json()) as { + payload: EnqueueEmailResponse; + }; + return json.payload; +} + +export async function getProgress(requestId: string): Promise { + const response = await fetch(`/api/email/progress?requestId=${requestId}`); + if (!response.ok) { + throw new Error( + `Progress API failed: ${response.status} ${response.statusText}`, + ); + } + const json = (await response.json()) as { + payload: EmailProgress; + }; + return json.payload; +} + +export async function listRequests(): Promise { + const response = await fetch("/api/email/requests"); + if (!response.ok) { + throw new Error( + `Requests API failed: ${response.status} ${response.statusText}`, + ); + } + const json = (await response.json()) as { + payload: EmailRequestSummary[]; + }; + return json.payload; +} + +export async function resendEmail(emailId: string): Promise { + const response = await fetch(`/api/email/${emailId}/resend`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + if (!response.ok) { + throw new Error( + `Resend API failed: ${response.status} ${response.statusText}`, + ); + } +} + +export async function listTemplates(): Promise { + const response = await fetch("/api/email/templates"); + if (!response.ok) { + throw new Error( + `Templates API failed: ${response.status} ${response.statusText}`, + ); + } + const json = (await response.json()) as { + payload: EmailTemplate[]; + }; + return json.payload; +} + +export async function triggerProcess(): Promise { + const response = await fetch("/api/email/process", { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + if (!response.ok) { + throw new Error( + `Process API failed: ${response.status} ${response.statusText}`, + ); + } +} + +export async function createTemplate(body: { + name: string; + subject: string; + body: string; +}): Promise { + const response = await fetch("/api/email/templates", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!response.ok) { + throw new Error( + `Create template API failed: ${response.status} ${response.statusText}`, + ); + } + const json = (await response.json()) as { + payload: EmailTemplate; + }; + return json.payload; +} + +export async function deleteTemplate(templateId: string): Promise { + const response = await fetch(`/api/email/templates/${templateId}`, { + method: "DELETE", + }); + if (!response.ok) { + throw new Error( + `Delete template API failed: ${response.status} ${response.statusText}`, + ); + } +} diff --git a/js/src/features/emails/api/parseCSV.ts b/js/src/features/emails/api/parseCSV.ts index 9c10ee0..85da9f2 100644 --- a/js/src/features/emails/api/parseCSV.ts +++ b/js/src/features/emails/api/parseCSV.ts @@ -1,20 +1,23 @@ -import type { User, Pair, SendRequest } from "@/features/emails/dto/emailDto"; +import type { + User, + Pair, + SendAsyncRequest, +} from "@/features/emails/dto/emailDto"; -import { emailTemplateMap } from "@/features/emails/api/emailTemplate"; import { parse, ParseResult } from "papaparse"; /** - * Takes parsed user/pair data and generates a SendRequest object based on the selected email template. + * Takes parsed user/pair data and generates a SendAsyncRequest object based on the selected template ID. * @param userMap - map of user data keyed by email address * @param pairList - list of user pairs - * @param template - the selected email template - * @returns SendRequest object to be sent to backend email API + * @param templateId - the selected template's ID (UUID string from backend) + * @returns SendAsyncRequest object to be sent to backend email API */ export const dataToSendRequest = async ( userMap: Map, pairList: Pair[], - template: string, -): Promise => { + templateId: string, +): Promise => { // Drop empty/whitespace-only values so the key is absent from variableToValue. The backend // resolves missing keys (not empty strings) to a template default via ${x:default} const withoutEmpty = (vars: Record): Record => @@ -61,16 +64,13 @@ export const dataToSendRequest = async ( })); } - const templateValue = emailTemplateMap[template]; - - const sendRequest = { - subject: templateValue.subject, - body: templateValue.body, - replyTo: templateValue.replyTo, + const sendAsyncRequest: SendAsyncRequest = { + templateId, + replyTo: null, messages, }; - return sendRequest; + return sendAsyncRequest; }; /** diff --git a/js/src/features/emails/dto/emailDto.ts b/js/src/features/emails/dto/emailDto.ts index 703b700..917cafd 100644 --- a/js/src/features/emails/dto/emailDto.ts +++ b/js/src/features/emails/dto/emailDto.ts @@ -37,3 +37,71 @@ export interface SendRequest { }[]; }[]; } + +// Async email flow with template ID (replaces SendRequest for new code) +export interface SendAsyncRequest { + templateId: string; + replyTo?: string | null; + messages: { + recipients: { + email: string; + variableToValue: Record; + }[]; + }[]; +} + +// Async email flow types + +export interface EnqueueEmailRequest { + templateId: string; + replyTo?: string | null; + messages: { + recipients: { + email: string; + variableToValue: Record; + }[]; + }[]; +} + +export interface EnqueueEmailResponse { + requestId: string; + accepted: number; +} + +export interface EmailTemplate { + id: string; + name: string; + subject: string; + body: string; + createdAt: string; + updatedAt: string; +} + +export interface EmailProgress { + total: number; + pending: number; + processing: number; + sent: number; + error: number; + emails: EmailRow[]; +} + +export interface EmailRow { + id: string; + recipients: string[]; + status: "PENDING" | "PROCESSING" | "SENT" | "ERROR"; + error: string | null; + sentAt: string | null; +} + +export interface EmailRequestSummary { + id: string; + source: "MANUAL" | "MATCHING"; + templateId: string | null; + createdAt: string; + total: number; + sent: number; + error: number; + inFlight: number; + terminal: boolean; +} From ebd603aae8b5475c6262435a68b3adbbc0d0e6ae Mon Sep 17 00:00:00 2001 From: Isabella Lam Date: Mon, 17 Aug 2026 21:20:46 -0400 Subject: [PATCH 2/2] move file to backend --- js/src/features/emails/EmailAdminPage.tsx | 4 +- .../emails/_components/CsvUploader.tsx | 8 +- .../emails/_components/EmailPreviewer.tsx | 4 +- .../emails/_components/EmailSender.tsx | 6 +- .../common/web/ApiExceptionHandler.java | 18 ++ .../web/exception/EmailNotFoundException.java | 10 ++ .../EmailNotResendableException.java | 10 ++ .../EmailTemplateNotFoundException.java | 10 ++ .../patchats/email/EmailController.java | 101 ++++++++++- .../patchats/email/EmailDrainer.java | 127 +++++++++++++ .../patchats/email/EmailEnqueueService.java | 119 +++++++++++++ .../patchats/email/EmailExecutorConfig.java | 28 +++ .../patchats/email/EmailProgressService.java | 68 +++++++ .../patchats/email/EmailRenderer.java | 27 +++ .../patchats/email/EmailService.java | 26 ++- .../email/TemplateManagementService.java | 100 +++++++++++ .../patchats/email/db/JsonbConverter.java | 41 +++++ .../patchats/email/db/models/Email.java | 49 +++++ .../email/db/models/EmailRequest.java | 30 ++++ .../email/db/models/EmailRequestCounts.java | 18 ++ .../patchats/email/db/models/EmailSource.java | 7 + .../patchats/email/db/models/EmailStatus.java | 9 + .../email/db/models/EmailTemplate.java | 28 +++ .../patchats/email/db/repos/EmailRepo.java | 47 +++++ .../email/db/repos/EmailRequestRepo.java | 15 ++ .../email/db/repos/EmailRequestSqlRepo.java | 82 +++++++++ .../patchats/email/db/repos/EmailSqlRepo.java | 167 ++++++++++++++++++ .../email/db/repos/EmailTemplateRepo.java | 32 ++++ .../email/db/repos/EmailTemplateSqlRepo.java | 82 +++++++++ .../email/dto/CreateTemplateRequest.java | 14 ++ .../email/dto/EmailProgressResponse.java | 13 ++ .../email/dto/EmailRequestSummary.java | 31 ++++ .../email/dto/EmailTemplateResponse.java | 18 ++ .../email/dto/EnqueueEmailRequest.java | 25 +++ .../email/dto/EnqueueEmailResponse.java | 10 ++ .../email/dto/PreviewTemplateRequest.java | 13 ++ .../patchats/email/EmailControllerTest.java | 57 ++++++ .../patchats/email/EmailDrainerTest.java | 157 ++++++++++++++++ .../email/EmailEnqueueServiceTest.java | 121 +++++++++++++ .../email/EmailProgressServiceTest.java | 115 ++++++++++++ 40 files changed, 1828 insertions(+), 19 deletions(-) create mode 100644 src/main/java/org/patinanetwork/patchats/common/web/exception/EmailNotFoundException.java create mode 100644 src/main/java/org/patinanetwork/patchats/common/web/exception/EmailNotResendableException.java create mode 100644 src/main/java/org/patinanetwork/patchats/common/web/exception/EmailTemplateNotFoundException.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/EmailDrainer.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/EmailEnqueueService.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/EmailExecutorConfig.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/EmailProgressService.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/EmailRenderer.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/TemplateManagementService.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/db/JsonbConverter.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/db/models/Email.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/db/models/EmailRequest.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/db/models/EmailRequestCounts.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/db/models/EmailSource.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/db/models/EmailStatus.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/db/models/EmailTemplate.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/db/repos/EmailRepo.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/db/repos/EmailRequestRepo.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/db/repos/EmailRequestSqlRepo.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/db/repos/EmailSqlRepo.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/db/repos/EmailTemplateRepo.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/db/repos/EmailTemplateSqlRepo.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/dto/CreateTemplateRequest.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/dto/EmailProgressResponse.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/dto/EmailRequestSummary.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/dto/EmailTemplateResponse.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/dto/EnqueueEmailRequest.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/dto/EnqueueEmailResponse.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/dto/PreviewTemplateRequest.java create mode 100644 src/test/java/org/patinanetwork/patchats/email/EmailDrainerTest.java create mode 100644 src/test/java/org/patinanetwork/patchats/email/EmailEnqueueServiceTest.java create mode 100644 src/test/java/org/patinanetwork/patchats/email/EmailProgressServiceTest.java diff --git a/js/src/features/emails/EmailAdminPage.tsx b/js/src/features/emails/EmailAdminPage.tsx index 6770adf..09bdf28 100644 --- a/js/src/features/emails/EmailAdminPage.tsx +++ b/js/src/features/emails/EmailAdminPage.tsx @@ -2,14 +2,14 @@ import { CsvUploader } from "@/features/emails/_components/CsvUploader"; import { EmailPreviewer } from "@/features/emails/_components/EmailPreviewer"; import { EmailSender } from "@/features/emails/_components/EmailSender"; import { + SendAsyncRequest, type MessagePreview, - type SendRequest, } from "@/features/emails/dto/emailDto"; import { Box, Flex, Stack } from "@mantine/core"; import { useState } from "react"; export default function EmailAdminPage() { - const [request, setRequest] = useState(null); + const [request, setRequest] = useState(null); const [previews, setPreviews] = useState(null); return ( diff --git a/js/src/features/emails/_components/CsvUploader.tsx b/js/src/features/emails/_components/CsvUploader.tsx index 7f1d6ca..579027c 100644 --- a/js/src/features/emails/_components/CsvUploader.tsx +++ b/js/src/features/emails/_components/CsvUploader.tsx @@ -1,4 +1,8 @@ -import type { Pair, SendRequest, User } from "@/features/emails/dto/emailDto"; +import type { + Pair, + SendAsyncRequest, + User, +} from "@/features/emails/dto/emailDto"; import { showEmailError } from "@/features/emails/api/emailError"; import { @@ -39,7 +43,7 @@ const rowStyle = { export function CsvUploader({ setRequest, }: { - setRequest: React.Dispatch>; + setRequest: React.Dispatch>; }) { const [userMap, setUserMap] = useState>(new Map()); const [pairList, setPairList] = useState([]); diff --git a/js/src/features/emails/_components/EmailPreviewer.tsx b/js/src/features/emails/_components/EmailPreviewer.tsx index 9a41db2..463ebac 100644 --- a/js/src/features/emails/_components/EmailPreviewer.tsx +++ b/js/src/features/emails/_components/EmailPreviewer.tsx @@ -1,6 +1,6 @@ import type { MessagePreview, - SendRequest, + SendAsyncRequest, } from "@/features/emails/dto/emailDto"; import { sendToPreviewApi } from "@/features/emails/api/emailAPI"; @@ -26,7 +26,7 @@ export function EmailPreviewer({ }: { previews: MessagePreview[] | null; setPreviews: React.Dispatch>; - request: SendRequest | null; + request: SendAsyncRequest | null; }) { const { data, status } = useQuery({ queryKey: ["preview", request], diff --git a/js/src/features/emails/_components/EmailSender.tsx b/js/src/features/emails/_components/EmailSender.tsx index 71088dc..2ce231b 100644 --- a/js/src/features/emails/_components/EmailSender.tsx +++ b/js/src/features/emails/_components/EmailSender.tsx @@ -1,4 +1,4 @@ -import type { SendRequest } from "@/features/emails/dto/emailDto"; +import type { SendAsyncRequest } from "@/features/emails/dto/emailDto"; import { sendToEmailApi } from "@/features/emails/api/emailAPI"; import { @@ -16,9 +16,9 @@ import { useEffect } from "react"; * @param request - The SendRequest object containing the email data to be sent. * @returns A button that, when clicked, opens a confirmation modal and sends the emails if confirmed. */ -export function EmailSender({ request }: { request: SendRequest | null }) { +export function EmailSender({ request }: { request: SendAsyncRequest | null }) { const mutation = useMutation({ - mutationFn: async (req: SendRequest) => sendToEmailApi(req), + mutationFn: async (req: SendAsyncRequest) => sendToEmailApi(req), }); useEffect(() => { if (mutation.status === "pending") { diff --git a/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java b/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java index 7501ad8..412c1d2 100644 --- a/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java +++ b/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java @@ -2,6 +2,9 @@ import java.util.stream.Collectors; import org.patinanetwork.patchats.common.dto.ApiResponder; +import org.patinanetwork.patchats.common.web.exception.EmailNotFoundException; +import org.patinanetwork.patchats.common.web.exception.EmailNotResendableException; +import org.patinanetwork.patchats.common.web.exception.EmailTemplateNotFoundException; import org.patinanetwork.patchats.common.web.exception.MemberDuplicateException; import org.patinanetwork.patchats.common.web.exception.MemberNotFoundException; import org.springframework.http.HttpStatus; @@ -34,6 +37,21 @@ public ResponseEntity> handleMemberDuplicate(final MemberDupl return ResponseEntity.status(HttpStatus.CONFLICT).body(ApiResponder.failure(ex.getMessage())); } + @ExceptionHandler(EmailTemplateNotFoundException.class) + public ResponseEntity> handleEmailTemplateNotFound(final EmailTemplateNotFoundException ex) { + return ResponseEntity.badRequest().body(ApiResponder.failure(ex.getMessage())); + } + + @ExceptionHandler(EmailNotFoundException.class) + public ResponseEntity> handleEmailNotFound(final EmailNotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponder.failure(ex.getMessage())); + } + + @ExceptionHandler(EmailNotResendableException.class) + public ResponseEntity> handleEmailNotResendable(final EmailNotResendableException ex) { + return ResponseEntity.status(HttpStatus.CONFLICT).body(ApiResponder.failure(ex.getMessage())); + } + private String formatError(final FieldError error) { return error.getField() + " " + error.getDefaultMessage(); } diff --git a/src/main/java/org/patinanetwork/patchats/common/web/exception/EmailNotFoundException.java b/src/main/java/org/patinanetwork/patchats/common/web/exception/EmailNotFoundException.java new file mode 100644 index 0000000..4162ca8 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/common/web/exception/EmailNotFoundException.java @@ -0,0 +1,10 @@ +package org.patinanetwork.patchats.common.web.exception; + +import java.util.UUID; + +/** No {@code emails} row exists with the given id. Surfaced as a 404. */ +public class EmailNotFoundException extends RuntimeException { + public EmailNotFoundException(final UUID id) { + super("Email with ID " + id + " not found"); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/common/web/exception/EmailNotResendableException.java b/src/main/java/org/patinanetwork/patchats/common/web/exception/EmailNotResendableException.java new file mode 100644 index 0000000..e1a357a --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/common/web/exception/EmailNotResendableException.java @@ -0,0 +1,10 @@ +package org.patinanetwork.patchats.common.web.exception; + +import java.util.UUID; + +/** A resend was requested for a row that is not in {@code ERROR}. Surfaced as a 409 (conflicting state). */ +public class EmailNotResendableException extends RuntimeException { + public EmailNotResendableException(final UUID id) { + super("Email with ID " + id + " is not in ERROR state and cannot be resent"); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/common/web/exception/EmailTemplateNotFoundException.java b/src/main/java/org/patinanetwork/patchats/common/web/exception/EmailTemplateNotFoundException.java new file mode 100644 index 0000000..0a6e80c --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/common/web/exception/EmailTemplateNotFoundException.java @@ -0,0 +1,10 @@ +package org.patinanetwork.patchats.common.web.exception; + +import java.util.UUID; + +/** A request referenced a {@code templateId} that does not exist. Surfaced as a 400 (client-supplied bad reference). */ +public class EmailTemplateNotFoundException extends RuntimeException { + public EmailTemplateNotFoundException(final UUID id) { + super("Email template with ID " + id + " not found"); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/email/EmailController.java b/src/main/java/org/patinanetwork/patchats/email/EmailController.java index d551017..84db964 100644 --- a/src/main/java/org/patinanetwork/patchats/email/EmailController.java +++ b/src/main/java/org/patinanetwork/patchats/email/EmailController.java @@ -4,19 +4,35 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import java.util.List; +import java.util.UUID; import lombok.RequiredArgsConstructor; import org.patinanetwork.patchats.common.dto.ApiResponder; +import org.patinanetwork.patchats.email.db.models.EmailSource; +import org.patinanetwork.patchats.email.db.repos.EmailTemplateRepo; +import org.patinanetwork.patchats.email.dto.CreateTemplateRequest; +import org.patinanetwork.patchats.email.dto.EmailProgressResponse; +import org.patinanetwork.patchats.email.dto.EmailRequestSummary; +import org.patinanetwork.patchats.email.dto.EmailTemplateResponse; +import org.patinanetwork.patchats.email.dto.EnqueueEmailRequest; +import org.patinanetwork.patchats.email.dto.EnqueueEmailResponse; import org.patinanetwork.patchats.email.dto.PreviewEmailResponse; +import org.patinanetwork.patchats.email.dto.PreviewTemplateRequest; import org.patinanetwork.patchats.email.dto.SendEmailRequest; import org.patinanetwork.patchats.email.dto.SendEmailResponse; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -/** REST endpoints for sending templated plain-text emails. */ +/** REST endpoints for sending templated plain-text emails (sync legacy path + async outbox pipeline). */ @RestController @RequestMapping("/api/email") @Tag(name = "Email") @@ -26,8 +42,13 @@ public class EmailController { private final EmailService emailService; + private final EmailEnqueueService enqueueService; + private final EmailProgressService progressService; + private final EmailDrainer drainer; + private final EmailTemplateRepo templateRepo; + private final TemplateManagementService templateManagementService; - @Operation(summary = "Send one or more templated plain-text emails") + @Operation(summary = "Send one or more templated plain-text emails (synchronous, legacy)") @PostMapping("/send") public ResponseEntity> send(@Valid @RequestBody final SendEmailRequest request) { final SendEmailResponse response = emailService.send(request); @@ -35,9 +56,83 @@ public ResponseEntity> send(@Valid @RequestBody return ResponseEntity.ok(ApiResponder.success(message, response)); } - @Operation(summary = "Render templated emails without sending them") + @Operation(summary = "Enqueue an async send; a background runner delivers each message") + @PostMapping("/send/async") + public ResponseEntity> sendAsync( + @Valid @RequestBody final EnqueueEmailRequest request) { + final EnqueueEmailResponse response = enqueueService.enqueue(request, EmailSource.MANUAL); + return ResponseEntity.status(HttpStatus.ACCEPTED) + .body(ApiResponder.success("Accepted %d emails".formatted(response.accepted()), response)); + } + + @Operation(summary = "Kick the background runner to drain any pending emails now") + @PostMapping("/process") + public ResponseEntity> process() { + drainer.trigger(); + return ResponseEntity.accepted().body(ApiResponder.success("Drain triggered", null)); + } + + @Operation(summary = "List the available email templates") + @GetMapping("/templates") + public ResponseEntity>> listTemplates() { + final List templates = + templateRepo.findAll().stream().map(EmailTemplateResponse::from).toList(); + return ResponseEntity.ok(ApiResponder.success("Found %d templates".formatted(templates.size()), templates)); + } + + @Operation(summary = "Create a new email template (Increment 5)") + @PostMapping("/templates") + public ResponseEntity> createTemplate( + @Valid @RequestBody final CreateTemplateRequest request) { + final UUID templateId = templateManagementService.createTemplate(request); + final EmailTemplateResponse response = templateRepo + .findById(templateId) + .map(EmailTemplateResponse::from) + .orElseThrow(); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponder.success("Template created: %s".formatted(request.name()), response)); + } + + @Operation(summary = "Delete an email template by ID (Increment 5)") + @DeleteMapping("/templates/{id}") + public ResponseEntity> deleteTemplate(@PathVariable final UUID id) { + templateManagementService.deleteTemplate(id); + return ResponseEntity.accepted().body(ApiResponder.success("Template deleted: %s".formatted(id), null)); + } + + @Operation(summary = "Live progress of one batch: per-status counts and the per-email rows") + @GetMapping("/progress") + public ResponseEntity> progress(@RequestParam final UUID requestId) { + final EmailProgressResponse response = progressService.progress(requestId); + return ResponseEntity.ok(ApiResponder.success("Progress for %s".formatted(requestId), response)); + } + + @Operation(summary = "History of past sending sessions, newest first") + @GetMapping("/requests") + public ResponseEntity>> requests() { + final List history = progressService.history(); + return ResponseEntity.ok(ApiResponder.success("Found %d sessions".formatted(history.size()), history)); + } + + @Operation(summary = "Re-queue a failed (ERROR) email and kick the runner to send it") + @PostMapping("/{emailId}/resend") + public ResponseEntity> resend(@PathVariable final UUID emailId) { + progressService.resend(emailId); + return ResponseEntity.accepted().body(ApiResponder.success("Re-queued %s".formatted(emailId), null)); + } + + @Operation(summary = "Render a stored template against messages without sending them") @PostMapping("/preview") public ResponseEntity> preview( + @Valid @RequestBody final PreviewTemplateRequest request) { + final PreviewEmailResponse response = enqueueService.preview(request); + return ResponseEntity.ok(ApiResponder.success( + "Rendered %d emails".formatted(response.previews().size()), response)); + } + + @Operation(summary = "Render caller-supplied subject/body templates without sending (synchronous, legacy)") + @PostMapping("/preview/legacy") + public ResponseEntity> previewLegacy( @Valid @RequestBody final SendEmailRequest request) { final PreviewEmailResponse response = emailService.preview(request); return ResponseEntity.ok(ApiResponder.success( diff --git a/src/main/java/org/patinanetwork/patchats/email/EmailDrainer.java b/src/main/java/org/patinanetwork/patchats/email/EmailDrainer.java new file mode 100644 index 0000000..76946f2 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/EmailDrainer.java @@ -0,0 +1,127 @@ +package org.patinanetwork.patchats.email; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; +import lombok.extern.slf4j.Slf4j; +import org.patinanetwork.patchats.email.db.models.Email; +import org.patinanetwork.patchats.email.db.models.EmailTemplate; +import org.patinanetwork.patchats.email.db.repos.EmailRepo; +import org.patinanetwork.patchats.email.db.repos.EmailTemplateRepo; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +/** + * On-demand background runner (decision #6). Drains the {@code emails} outbox: claims a small batch, renders each row + * from its template, sends over SMTP, and records a terminal status — one attempt, no retry (decision #8). + * + *

Started only by an explicit kick ({@link #trigger()}, from {@code POST /api/email/process}) or the startup drain; + * there is no enqueue-time auto-trigger and no polling. Runs single-threaded so overlapping triggers coalesce. + */ +@Component +@Slf4j +public class EmailDrainer { + + private static final int BATCH_SIZE = 50; + + private final EmailRepo emailRepo; + private final EmailTemplateRepo templateRepo; + private final EmailRenderer renderer; + private final EmailSender sender; + private final Executor executor; + + /** True while a drain job is running; guards against launching a second overlapping drain. */ + private final AtomicBoolean running = new AtomicBoolean(false); + /** Set by a trigger that arrives during a drain, so the running drain loops once more instead of exiting. */ + private final AtomicBoolean rerun = new AtomicBoolean(false); + + public EmailDrainer( + final EmailRepo emailRepo, + final EmailTemplateRepo templateRepo, + final EmailRenderer renderer, + final EmailSender sender, + @Qualifier("emailDrainExecutor") final Executor executor) { + this.emailRepo = emailRepo; + this.templateRepo = templateRepo; + this.renderer = renderer; + this.sender = sender; + this.executor = executor; + } + + /** + * Requests a drain. If one is already running, flags it to loop again; otherwise submits a fresh drain job. Returns + * immediately — the drain runs on the executor thread. + */ + public void trigger() { + rerun.set(true); + if (running.compareAndSet(false, true)) { + executor.execute(this::drainLoop); + } + } + + private void drainLoop() { + try { + do { + rerun.set(false); + drainAll(); + } while (rerun.get()); + } finally { + running.set(false); + } + // A trigger racing between the last rerun check and clearing `running` must not be lost. + if (rerun.get() && running.compareAndSet(false, true)) { + executor.execute(this::drainLoop); + } + } + + private void drainAll() { + // Cache templates for the life of one drain so a batch of the same template loads it once. + final Map templateCache = new HashMap<>(); + List batch = emailRepo.claimBatch(BATCH_SIZE); + while (!batch.isEmpty()) { + for (final Email email : batch) { + sendOne(email, templateCache); + } + batch = emailRepo.claimBatch(BATCH_SIZE); + } + } + + private void sendOne(final Email email, final Map templateCache) { + try { + final EmailTemplate template = templateCache.computeIfAbsent(email.getTemplateId(), id -> templateRepo + .findById(id) + .orElseThrow(() -> new IllegalStateException("Template " + id + " no longer exists"))); + final EmailRenderer.RenderedEmail rendered = renderer.render(template, email.getTemplateValues()); + final List recipients = email.getRecipient2() == null + ? List.of(email.getRecipient1()) + : List.of(email.getRecipient1(), email.getRecipient2()); + sender.send(new OutgoingEmail( + recipients, rendered.subject(), rendered.body(), Optional.ofNullable(email.getReplyTo()))); + emailRepo.markSent(email.getId()); + log.info("Sent email {} to {}", email.getId(), recipients); + } catch (final RuntimeException ex) { + // Any failure — SMTP or a render error (missing variable / malformed template) — is terminal (no retry). + log.warn("Email {} failed: {}", email.getId(), ex.getMessage()); + emailRepo.markError(email.getId(), ex.getMessage()); + } + } + + /** + * On boot: reset orphaned {@code PROCESSING} rows to {@code ERROR} (at-most-once recovery, decision #9), then kick + * one drain to cover rows left {@code PENDING} before shutdown — the only safety net for a missed kick. + */ + @EventListener(ApplicationReadyEvent.class) + public void onApplicationReady() { + final int reset = emailRepo.resetProcessingToError(); + if (reset > 0) { + log.warn("Reset {} orphaned PROCESSING email(s) to ERROR on startup", reset); + } + trigger(); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/email/EmailEnqueueService.java b/src/main/java/org/patinanetwork/patchats/email/EmailEnqueueService.java new file mode 100644 index 0000000..0885922 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/EmailEnqueueService.java @@ -0,0 +1,119 @@ +package org.patinanetwork.patchats.email; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.TextStyle; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.patinanetwork.patchats.common.web.exception.EmailTemplateNotFoundException; +import org.patinanetwork.patchats.email.db.models.Email; +import org.patinanetwork.patchats.email.db.models.EmailRequest; +import org.patinanetwork.patchats.email.db.models.EmailSource; +import org.patinanetwork.patchats.email.db.models.EmailStatus; +import org.patinanetwork.patchats.email.db.models.EmailTemplate; +import org.patinanetwork.patchats.email.db.repos.EmailRepo; +import org.patinanetwork.patchats.email.db.repos.EmailRequestRepo; +import org.patinanetwork.patchats.email.db.repos.EmailTemplateRepo; +import org.patinanetwork.patchats.email.dto.EnqueueEmailRequest; +import org.patinanetwork.patchats.email.dto.EnqueueEmailResponse; +import org.patinanetwork.patchats.email.dto.PreviewEmailResponse; +import org.patinanetwork.patchats.email.dto.PreviewTemplateRequest; +import org.patinanetwork.patchats.email.dto.SendEmailRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Producer side of the async pipeline: validates the template, merges each message's variables, and enqueues one parent + * {@code email_requests} session plus N {@code emails} outbox rows in a single transaction. It never renders or sends — + * rendering happens later in the {@link EmailDrainer} at send-time (decision #4). The service does not start the + * runner; the caller kicks the drain via {@code POST /api/email/process} after the enqueue transaction commits + * (decision #6). + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class EmailEnqueueService { + + /** US East Coast zone; {@code America/New_York} tracks the EST/EDT daylight-saving switch automatically. */ + private static final ZoneId EAST_COAST = ZoneId.of("America/New_York"); + + private final EmailTemplateRepo templateRepo; + private final EmailRequestRepo requestRepo; + private final EmailRepo emailRepo; + private final EmailRenderer renderer; + + /** Enqueues a batch. {@code source} distinguishes the producer (MANUAL vs MATCHING). */ + @Transactional + public EnqueueEmailResponse enqueue(final EnqueueEmailRequest request, final EmailSource source) { + final EmailTemplate template = templateRepo + .findById(request.templateId()) + .orElseThrow(() -> new EmailTemplateNotFoundException(request.templateId())); + + final UUID requestId = UUID.randomUUID(); + requestRepo.insert(EmailRequest.builder() + .id(requestId) + .source(source) + .templateId(template.getId()) + .totalCount(request.messages().size()) + .build()); + + // Fill in the send-time month once for the whole batch so ${month} resolves consistently. Callers can + // still override it by passing an explicit "month" variable (putIfAbsent below leaves theirs untouched). + final String currentMonth = LocalDate.now(EAST_COAST).getMonth().getDisplayName(TextStyle.FULL, Locale.ENGLISH); + + final List emails = new ArrayList<>(request.messages().size()); + for (final EnqueueEmailRequest.Message message : request.messages()) { + final Map variables = + EmailService.mergeVariables(message.variables(), message.recipients()); + variables.putIfAbsent("month", currentMonth); + final List recipients = message.recipients(); + emails.add(Email.builder() + .id(UUID.randomUUID()) + .requestId(requestId) + .recipient1(recipients.get(0).email()) + .recipient2(recipients.size() > 1 ? recipients.get(1).email() : null) + .replyTo(request.replyTo()) + .templateId(template.getId()) + .templateValues(variables) + .status(EmailStatus.PENDING) + .build()); + } + emailRepo.insertAll(emails); + log.info("Enqueued request {} ({} emails, source={})", requestId, emails.size(), source); + return new EnqueueEmailResponse(requestId, emails.size()); + } + + /** + * Renders the referenced template against each message without sending or persisting. Best-effort: a per-message + * render failure is reported in that message's {@code error} (mirrors the sync preview model). + */ + public PreviewEmailResponse preview(final PreviewTemplateRequest request) { + final EmailTemplate template = templateRepo + .findById(request.templateId()) + .orElseThrow(() -> new EmailTemplateNotFoundException(request.templateId())); + + final List previews = new ArrayList<>(); + for (final EnqueueEmailRequest.Message message : request.messages()) { + final List recipients = message.recipients().stream() + .map(SendEmailRequest.Recipient::email) + .toList(); + try { + final Map variables = + EmailService.mergeVariables(message.variables(), message.recipients()); + variables.putIfAbsent( + "month", LocalDate.now(EAST_COAST).getMonth().getDisplayName(TextStyle.FULL, Locale.ENGLISH)); + final EmailRenderer.RenderedEmail rendered = renderer.render(template, variables); + previews.add( + new PreviewEmailResponse.MessagePreview(recipients, rendered.subject(), rendered.body(), null)); + } catch (final RuntimeException ex) { + previews.add(new PreviewEmailResponse.MessagePreview(recipients, null, null, ex.getMessage())); + } + } + return new PreviewEmailResponse(previews); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/email/EmailExecutorConfig.java b/src/main/java/org/patinanetwork/patchats/email/EmailExecutorConfig.java new file mode 100644 index 0000000..07554ec --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/EmailExecutorConfig.java @@ -0,0 +1,28 @@ +package org.patinanetwork.patchats.email; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +/** + * Single-thread executor backing {@link EmailDrainer}. Core = max = 1 so drains serialise and overlapping triggers + * coalesce onto one drain (decision #6). Waits for an in-flight drain on shutdown so a stop-then-start deploy does not + * strand a claimed batch (decision #5). + */ +@Configuration +public class EmailExecutorConfig { + + @Bean + public ThreadPoolTaskExecutor emailDrainExecutor() { + final ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(1); + executor.setMaxPoolSize(1); + // A tiny queue is enough: the drainer coalesces triggers itself, so at most one job is ever queued. + executor.setQueueCapacity(1); + executor.setThreadNamePrefix("email-drain-"); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(30); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/org/patinanetwork/patchats/email/EmailProgressService.java b/src/main/java/org/patinanetwork/patchats/email/EmailProgressService.java new file mode 100644 index 0000000..5cb9fc0 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/EmailProgressService.java @@ -0,0 +1,68 @@ +package org.patinanetwork.patchats.email; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.patinanetwork.patchats.common.web.exception.EmailNotFoundException; +import org.patinanetwork.patchats.common.web.exception.EmailNotResendableException; +import org.patinanetwork.patchats.email.db.models.Email; +import org.patinanetwork.patchats.email.db.models.EmailStatus; +import org.patinanetwork.patchats.email.db.repos.EmailRepo; +import org.patinanetwork.patchats.email.db.repos.EmailRequestRepo; +import org.patinanetwork.patchats.email.dto.EmailProgressResponse; +import org.patinanetwork.patchats.email.dto.EmailRequestSummary; +import org.springframework.stereotype.Service; + +/** Read/UX-support layer over the pipeline: batch progress, session history, and manual resend of failed rows. */ +@Service +@RequiredArgsConstructor +public class EmailProgressService { + + private final EmailRepo emailRepo; + private final EmailRequestRepo requestRepo; + private final EmailDrainer drainer; + + /** Live per-status counts + the per-email rows for one batch. */ + public EmailProgressResponse progress(final UUID requestId) { + final Map counts = emailRepo.countByStatus(requestId); + final List rows = emailRepo.findByRequest(requestId); + + final List summaries = new ArrayList<>(rows.size()); + for (final Email email : rows) { + final List recipients = email.getRecipient2() == null + ? List.of(email.getRecipient1()) + : List.of(email.getRecipient1(), email.getRecipient2()); + summaries.add(new EmailProgressResponse.EmailSummary( + email.getId(), recipients, email.getStatus().name(), email.getErrorMessage(), email.getSentAt())); + } + + final int pending = counts.getOrDefault(EmailStatus.PENDING, 0); + final int processing = counts.getOrDefault(EmailStatus.PROCESSING, 0); + final int sent = counts.getOrDefault(EmailStatus.SENT, 0); + final int error = counts.getOrDefault(EmailStatus.ERROR, 0); + return new EmailProgressResponse( + pending + processing + sent + error, pending, processing, sent, error, summaries); + } + + /** History of past sending sessions, newest first, each flagged {@code terminal} when nothing is in flight. */ + public List history() { + return requestRepo.listWithCounts().stream() + .map(EmailRequestSummary::from) + .toList(); + } + + /** + * Manual recovery required by the at-most-once model (decision #9): flips an {@code ERROR} row back to + * {@code PENDING} and kicks the drain. 404 if the row is unknown, 409 if it is not currently {@code ERROR}. + */ + public void resend(final UUID emailId) { + final int updated = emailRepo.markPendingIfError(emailId); + if (updated == 0) { + emailRepo.findById(emailId).orElseThrow(() -> new EmailNotFoundException(emailId)); + throw new EmailNotResendableException(emailId); + } + drainer.trigger(); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/email/EmailRenderer.java b/src/main/java/org/patinanetwork/patchats/email/EmailRenderer.java new file mode 100644 index 0000000..28fae08 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/EmailRenderer.java @@ -0,0 +1,27 @@ +package org.patinanetwork.patchats.email; + +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.patinanetwork.patchats.email.db.models.EmailTemplate; +import org.springframework.stereotype.Component; + +/** + * Renders a stored {@link EmailTemplate}'s subject and body from a variable map. This is the single render seam shared + * by {@code /preview} and the {@code EmailDrainer} runner (decision #4) — so a preview renders byte-for-byte what the + * runner will actually send, with no drift between the two. + */ +@Component +@RequiredArgsConstructor +public class EmailRenderer { + + private final TemplateRenderer renderer; + + /** @throws IllegalArgumentException if a required {@code ${}} placeholder has neither a value nor a default */ + public RenderedEmail render(final EmailTemplate template, final Map variables) { + return new RenderedEmail( + renderer.render(template.getSubject(), variables), renderer.render(template.getBody(), variables)); + } + + /** Rendered subject/body ready to hand to {@code EmailSender}. */ + public record RenderedEmail(String subject, String body) {} +} diff --git a/src/main/java/org/patinanetwork/patchats/email/EmailService.java b/src/main/java/org/patinanetwork/patchats/email/EmailService.java index 3386070..de9c459 100644 --- a/src/main/java/org/patinanetwork/patchats/email/EmailService.java +++ b/src/main/java/org/patinanetwork/patchats/email/EmailService.java @@ -37,7 +37,7 @@ public SendEmailResponse send(final SendEmailRequest request) { .map(SendEmailRequest.Recipient::email) .toList(); try { - final Map variables = mergeVariables(message); + final Map variables = mergeVariables(message.variables(), message.recipients()); final String subject = renderer.render(request.subject(), variables); final String body = renderer.render(request.body(), variables); sender.send(new OutgoingEmail(recipients, subject, body, replyTo)); @@ -65,7 +65,7 @@ public PreviewEmailResponse preview(final SendEmailRequest request) { .map(SendEmailRequest.Recipient::email) .toList(); try { - final Map variables = mergeVariables(message); + final Map variables = mergeVariables(message.variables(), message.recipients()); final String subject = renderer.render(request.subject(), variables); final String body = renderer.render(request.body(), variables); previews.add(new PreviewEmailResponse.MessagePreview(recipients, subject, body, null)); @@ -78,19 +78,31 @@ public PreviewEmailResponse preview(final SendEmailRequest request) { /** * Flattens a message's variables into one map: message-level variables un-prefixed, and each recipient's variables - * under a positional {@code per1.}/{@code per2.} prefix. + * under a positional {@code per1.}/{@code per2.} prefix. Shared by the sync send/preview paths and the async + * enqueue pipeline so the stored {@code template_values} match exactly what a preview renders. */ - private static Map mergeVariables(final SendEmailRequest.Message message) { + public static Map mergeVariables( + final Map messageVariables, final List recipients) { final Map merged = new HashMap<>(); - if (message.variables() != null) { - merged.putAll(message.variables()); + if (messageVariables != null) { + merged.putAll(messageVariables); } - final List recipients = message.recipients(); for (int i = 0; i < recipients.size(); i++) { final String prefix = "per" + (i + 1) + "."; final Map vars = recipients.get(i).variableToValue(); if (vars != null) { vars.forEach((key, value) -> merged.put(prefix + key, value)); + + // manually add per#.name + final String firstName = vars.get("firstName"); + final String lastName = vars.get("lastName"); + if (firstName != null && lastName != null) { + merged.put(prefix + "name", firstName + " " + lastName); + } else if (firstName != null) { + merged.put(prefix + "name", firstName); + } else if (lastName != null) { + merged.put(prefix + "name", lastName); + } } } return merged; diff --git a/src/main/java/org/patinanetwork/patchats/email/TemplateManagementService.java b/src/main/java/org/patinanetwork/patchats/email/TemplateManagementService.java new file mode 100644 index 0000000..23f1340 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/TemplateManagementService.java @@ -0,0 +1,100 @@ +package org.patinanetwork.patchats.email; + +import java.util.Map; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.patinanetwork.patchats.email.db.models.EmailTemplate; +import org.patinanetwork.patchats.email.db.repos.EmailTemplateRepo; +import org.patinanetwork.patchats.email.dto.CreateTemplateRequest; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +/** + * Manages template CRUD (Increment 5): validates syntax, enforces uniqueness, and protects referenced templates from + * deletion. Templates are immutable — no edit endpoint. + * + *

Fully wired and tested on the backend: reachable via {@code POST}/{@code DELETE /api/email/templates} (see + * {@link EmailController}). Not yet reachable through the running UI — the frontend component that calls these + * endpoints ({@code TemplateManager.tsx}) is built but not mounted in any route or in {@code EmailAdminPage}, so the + * create/delete flow is currently exercised only by HTTP clients and tests. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class TemplateManagementService { + + private final EmailTemplateRepo templateRepo; + private final EmailRenderer renderer; + + /** + * Creates a new template after validating the name (unique, non-blank) and syntax (dry-run render against sample + * vars). Rejects if any validation fails (e.g., malformed ${} syntax, duplicate name). + */ + @Transactional + public UUID createTemplate(final CreateTemplateRequest request) { + // Validate uniqueness + if (templateRepo.nameExists(request.name())) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Template name '%s' is already in use".formatted(request.name())); + } + + // Validate syntax by attempting a dry-run render with sample + shared variables + final Map sampleVars = Map.ofEntries( + Map.entry("month", "Sample Month"), + Map.entry("per1.name", "Sample Person One"), + Map.entry("per1.email", "sample.one@example.com"), + Map.entry("per1.bio", "Sample bio for person one"), + Map.entry("per1.industry", "Sample Industry"), + Map.entry("per1.role", "Sample Role"), + Map.entry("per1.topics", "Sample Topic A, Sample Topic B"), + Map.entry("per1.linkedUrl", "https://example.com/sample-one"), + Map.entry("per2.name", "Sample Person Two"), + Map.entry("per2.email", "sample.two@example.com"), + Map.entry("per2.bio", "Sample bio for person two"), + Map.entry("per2.industry", "Sample Industry"), + Map.entry("per2.role", "Sample Role"), + Map.entry("per2.topics", "Sample Topic C, Sample Topic D"), + Map.entry("per2.linkedUrl", "https://example.com/sample-two")); + + try { + final EmailTemplate templateToValidate = EmailTemplate.builder() + .subject(request.subject()) + .body(request.body()) + .build(); + renderer.render(templateToValidate, sampleVars); + } catch (final Exception e) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Template syntax error: %s".formatted(e.getMessage()), e); + } + + // Create and return the new template's UUID + final UUID templateId = templateRepo.create(request.name(), request.subject(), request.body()); + log.info("Created template {} ({})", templateId, request.name()); + return templateId; + } + + /** + * Soft-deletes a template. Rejects if the template is referenced by any non-ERROR email rows (the dedup guard logic + * requires immutability: a queued row must never have its template deleted out from under it). Uses soft-delete so + * past audit history is preserved. + */ + @Transactional + public void deleteTemplate(final UUID templateId) { + final long referencingCount = templateRepo.countEmailsReferencing(templateId); + if (referencingCount > 0) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Cannot delete template %s: %d email(s) reference it".formatted(templateId, referencingCount)); + } + + final int updated = templateRepo.softDelete(templateId); + if (updated == 0) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Template %s not found or already deleted".formatted(templateId)); + } + log.info("Soft-deleted template {}", templateId); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/email/db/JsonbConverter.java b/src/main/java/org/patinanetwork/patchats/email/db/JsonbConverter.java new file mode 100644 index 0000000..878e809 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/db/JsonbConverter.java @@ -0,0 +1,41 @@ +package org.patinanetwork.patchats.email.db; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * Converts a template-variable {@code Map} to/from the {@code JSONB} text stored in + * {@code emails.template_values}. SQL binds the string with a {@code ::jsonb} cast; reads come back as text. + */ +@Component +@RequiredArgsConstructor +public class JsonbConverter { + + private static final TypeReference> STRING_MAP = new TypeReference<>() {}; + + private final ObjectMapper objectMapper; + + /** Serialises a variable map to a JSON object string (never null; an empty map becomes {@code "{}"}). */ + public String toJson(final Map values) { + try { + return objectMapper.writeValueAsString(values == null ? Map.of() : values); + } catch (final com.fasterxml.jackson.core.JsonProcessingException ex) { + throw new IllegalArgumentException("Could not serialise template values to JSON", ex); + } + } + + /** Parses stored JSONB text back into a variable map ({@code null}/blank becomes an empty map). */ + public Map toMap(final String json) { + if (json == null || json.isBlank()) { + return Map.of(); + } + try { + return objectMapper.readValue(json, STRING_MAP); + } catch (final com.fasterxml.jackson.core.JsonProcessingException ex) { + throw new IllegalArgumentException("Could not parse template values JSON", ex); + } + } +} diff --git a/src/main/java/org/patinanetwork/patchats/email/db/models/Email.java b/src/main/java/org/patinanetwork/patchats/email/db/models/Email.java new file mode 100644 index 0000000..1cc052a --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/db/models/Email.java @@ -0,0 +1,49 @@ +package org.patinanetwork.patchats.email.db.models; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +/** + * One row of the {@code emails} outbox — a single message addressed to 1–2 recipients. The runner renders + * {@code subject}/{@code body} from {@link #templateId} + {@link #templateValues} at send-time; rendered text is never + * stored (decision #4). + */ +@Getter +@Builder +@ToString +@EqualsAndHashCode +public class Email { + + private UUID id; + + private UUID requestId; + + private String recipient1; + + private String recipient2; + + private String replyTo; + + private UUID templateId; + + private Map templateValues; + + @Setter + private EmailStatus status; + + @Setter + private String errorMessage; + + private Instant createdAt; + + private Instant updatedAt; + + @Setter + private Instant sentAt; +} diff --git a/src/main/java/org/patinanetwork/patchats/email/db/models/EmailRequest.java b/src/main/java/org/patinanetwork/patchats/email/db/models/EmailRequest.java new file mode 100644 index 0000000..f418358 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/db/models/EmailRequest.java @@ -0,0 +1,30 @@ +package org.patinanetwork.patchats.email.db.models; + +import java.time.Instant; +import java.util.UUID; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; + +/** One "sending session" — the parent of N {@link Email} rows and the unit of the history tab. */ +@Getter +@Builder +@ToString +@EqualsAndHashCode +public class EmailRequest { + + private UUID id; + + private String label; + + private String senderEmail; + + private EmailSource source; + + private UUID templateId; + + private int totalCount; + + private Instant createdAt; +} diff --git a/src/main/java/org/patinanetwork/patchats/email/db/models/EmailRequestCounts.java b/src/main/java/org/patinanetwork/patchats/email/db/models/EmailRequestCounts.java new file mode 100644 index 0000000..ae62eab --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/db/models/EmailRequestCounts.java @@ -0,0 +1,18 @@ +package org.patinanetwork.patchats.email.db.models; + +import java.time.Instant; +import java.util.UUID; + +/** + * A session row joined with aggregated child counts — the history-list unit (Increment 2). {@code inFlight} counts rows + * still {@code PENDING}/{@code PROCESSING}; a session is terminal when it is zero. + */ +public record EmailRequestCounts( + UUID id, + EmailSource source, + UUID templateId, + Instant createdAt, + int totalCount, + int sent, + int error, + int inFlight) {} diff --git a/src/main/java/org/patinanetwork/patchats/email/db/models/EmailSource.java b/src/main/java/org/patinanetwork/patchats/email/db/models/EmailSource.java new file mode 100644 index 0000000..c0728b3 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/db/models/EmailSource.java @@ -0,0 +1,7 @@ +package org.patinanetwork.patchats.email.db.models; + +/** Which producer enqueued a sending session. */ +public enum EmailSource { + MANUAL, + MATCHING +} diff --git a/src/main/java/org/patinanetwork/patchats/email/db/models/EmailStatus.java b/src/main/java/org/patinanetwork/patchats/email/db/models/EmailStatus.java new file mode 100644 index 0000000..ec3457f --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/db/models/EmailStatus.java @@ -0,0 +1,9 @@ +package org.patinanetwork.patchats.email.db.models; + +/** Lifecycle of a single outbox row. Terminal states are {@link #SENT} and {@link #ERROR}. */ +public enum EmailStatus { + PENDING, + PROCESSING, + SENT, + ERROR +} diff --git a/src/main/java/org/patinanetwork/patchats/email/db/models/EmailTemplate.java b/src/main/java/org/patinanetwork/patchats/email/db/models/EmailTemplate.java new file mode 100644 index 0000000..f763eb5 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/db/models/EmailTemplate.java @@ -0,0 +1,28 @@ +package org.patinanetwork.patchats.email.db.models; + +import java.time.Instant; +import java.util.UUID; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; + +/** A reusable {@code ${}} subject/body template. Immutable once created (see decision #15). */ +@Getter +@Builder +@ToString +@EqualsAndHashCode +public class EmailTemplate { + + private UUID id; + + private String name; + + private String subject; + + private String body; + + private Instant createdAt; + + private Instant updatedAt; +} diff --git a/src/main/java/org/patinanetwork/patchats/email/db/repos/EmailRepo.java b/src/main/java/org/patinanetwork/patchats/email/db/repos/EmailRepo.java new file mode 100644 index 0000000..926fb40 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/db/repos/EmailRepo.java @@ -0,0 +1,47 @@ +package org.patinanetwork.patchats.email.db.repos; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.patinanetwork.patchats.email.db.models.Email; +import org.patinanetwork.patchats.email.db.models.EmailStatus; + +/** Access to the {@code emails} outbox: enqueue children, claim a batch, update per-row status, boot recovery. */ +public interface EmailRepo { + + /** Batch-inserts the outbox children (one transaction with the parent, see {@code EmailEnqueueService}). */ + void insertAll(List emails); + + /** + * Atomically claims up to {@code limit} of the oldest {@code PENDING} rows, flipping them to {@code PROCESSING}, + * and returns them. Single-instance deployment means no row-locking is required (decision #5). + */ + List claimBatch(int limit); + + /** Marks a row {@code SENT} with {@code sent_at = now()}. */ + void markSent(UUID id); + + /** Marks a row {@code ERROR} with the given message (no retry — terminal, decision #8). */ + void markError(UUID id, String errorMessage); + + /** + * At-most-once crash recovery (decision #9): flips any orphaned {@code PROCESSING} rows to {@code ERROR}. Returns + * the number of rows reset. + */ + int resetProcessingToError(); + + /** Per-status row counts for one batch ({@code GROUP BY status}); statuses with no rows are absent. */ + Map countByStatus(UUID requestId); + + /** All rows of one batch, oldest first, for the per-email progress table. */ + List findByRequest(UUID requestId); + + Optional findById(UUID id); + + /** + * Flips a row {@code ERROR → PENDING} and clears its error message, but only if it is currently {@code ERROR}. + * Returns the number of rows changed (1 on success, 0 if the row was not in {@code ERROR}). + */ + int markPendingIfError(UUID id); +} diff --git a/src/main/java/org/patinanetwork/patchats/email/db/repos/EmailRequestRepo.java b/src/main/java/org/patinanetwork/patchats/email/db/repos/EmailRequestRepo.java new file mode 100644 index 0000000..b9d8825 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/db/repos/EmailRequestRepo.java @@ -0,0 +1,15 @@ +package org.patinanetwork.patchats.email.db.repos; + +import java.util.List; +import org.patinanetwork.patchats.email.db.models.EmailRequest; +import org.patinanetwork.patchats.email.db.models.EmailRequestCounts; + +/** Writes the parent {@code email_requests} session row and reads the history list with aggregated child counts. */ +public interface EmailRequestRepo { + + /** Inserts the session row and returns it as persisted (with DB-populated {@code created_at}). */ + EmailRequest insert(EmailRequest request); + + /** History list for the sessions tab: one entry per session with child counts, newest first. */ + List listWithCounts(); +} diff --git a/src/main/java/org/patinanetwork/patchats/email/db/repos/EmailRequestSqlRepo.java b/src/main/java/org/patinanetwork/patchats/email/db/repos/EmailRequestSqlRepo.java new file mode 100644 index 0000000..62bcc58 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/db/repos/EmailRequestSqlRepo.java @@ -0,0 +1,82 @@ +package org.patinanetwork.patchats.email.db.repos; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.patinanetwork.patchats.email.db.models.EmailRequest; +import org.patinanetwork.patchats.email.db.models.EmailRequestCounts; +import org.patinanetwork.patchats.email.db.models.EmailSource; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class EmailRequestSqlRepo implements EmailRequestRepo { + + private static final String TEMPLATE_ID = "template_id"; + private static final String SOURCE = "source"; + private static final String TOTAL_COUNT = "total_count"; + + private final JdbcClient jdbc; + + static EmailRequest parseResultSet(final ResultSet rs) throws SQLException { + final String templateId = rs.getString(TEMPLATE_ID); + return EmailRequest.builder() + .id(UUID.fromString(rs.getString("id"))) + .label(rs.getString("label")) + .senderEmail(rs.getString("sender_email")) + .source(EmailSource.valueOf(rs.getString(SOURCE))) + .templateId(templateId == null ? null : UUID.fromString(templateId)) + .totalCount(rs.getInt(TOTAL_COUNT)) + .createdAt(rs.getTimestamp("created_at").toInstant()) + .build(); + } + + @Override + public EmailRequest insert(final EmailRequest request) { + final String sql = """ + INSERT INTO "email_requests" (id, label, sender_email, source, template_id, total_count) + VALUES (:id, :label, :sender_email, :source, :template_id, :total_count) + RETURNING * + """; + return jdbc.sql(sql) + .param("id", request.getId()) + .param("label", request.getLabel()) + .param("sender_email", request.getSenderEmail()) + .param(SOURCE, request.getSource().name()) + .param(TEMPLATE_ID, request.getTemplateId()) + .param(TOTAL_COUNT, request.getTotalCount()) + .query((rs, rowNum) -> parseResultSet(rs)) + .single(); + } + + @Override + public List listWithCounts() { + final String sql = """ + SELECT r.id, r.source, r.template_id, r.created_at, r.total_count, + count(*) FILTER (WHERE e.status = 'SENT') AS sent, + count(*) FILTER (WHERE e.status = 'ERROR') AS error, + count(*) FILTER (WHERE e.status IN ('PENDING', 'PROCESSING')) AS in_flight + FROM email_requests r + JOIN emails e ON e.request_id = r.id + GROUP BY r.id + ORDER BY r.created_at DESC + """; + return jdbc.sql(sql) + .query((rs, rowNum) -> { + final String templateId = rs.getString(TEMPLATE_ID); + return new EmailRequestCounts( + UUID.fromString(rs.getString("id")), + EmailSource.valueOf(rs.getString(SOURCE)), + templateId == null ? null : UUID.fromString(templateId), + rs.getTimestamp("created_at").toInstant(), + rs.getInt(TOTAL_COUNT), + rs.getInt("sent"), + rs.getInt("error"), + rs.getInt("in_flight")); + }) + .list(); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/email/db/repos/EmailSqlRepo.java b/src/main/java/org/patinanetwork/patchats/email/db/repos/EmailSqlRepo.java new file mode 100644 index 0000000..49e8acd --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/db/repos/EmailSqlRepo.java @@ -0,0 +1,167 @@ +package org.patinanetwork.patchats.email.db.repos; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.patinanetwork.patchats.email.db.JsonbConverter; +import org.patinanetwork.patchats.email.db.models.Email; +import org.patinanetwork.patchats.email.db.models.EmailStatus; +import org.postgresql.util.PGobject; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class EmailSqlRepo implements EmailRepo { + + private final JdbcClient jdbc; + // JdbcClient has no batch API yet, so the N-child insert drops to JdbcTemplate (decision #19). + private final JdbcTemplate jdbcTemplate; + private final JsonbConverter jsonb; + + private Email parseResultSet(final ResultSet rs) throws SQLException { + final Timestamp sentAt = rs.getTimestamp("sent_at"); + return Email.builder() + .id(UUID.fromString(rs.getString("id"))) + .requestId(UUID.fromString(rs.getString("request_id"))) + .recipient1(rs.getString("recipient_1")) + .recipient2(rs.getString("recipient_2")) + .replyTo(rs.getString("reply_to")) + .templateId(UUID.fromString(rs.getString("template_id"))) + .templateValues(jsonb.toMap(rs.getString("template_values"))) + .status(EmailStatus.valueOf(rs.getString("status"))) + .errorMessage(rs.getString("error_message")) + .createdAt(rs.getTimestamp("created_at").toInstant()) + .updatedAt(rs.getTimestamp("updated_at").toInstant()) + .sentAt(sentAt == null ? null : sentAt.toInstant()) + .build(); + } + + private static PGobject jsonbObject(final String json) throws SQLException { + final PGobject pg = new PGobject(); + pg.setType("jsonb"); + pg.setValue(json); + return pg; + } + + @Override + public void insertAll(final List emails) { + final String sql = """ + INSERT INTO "emails" ( + id, request_id, recipient_1, recipient_2, reply_to, + template_id, template_values, status + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """; + jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { + @Override + public void setValues(final PreparedStatement ps, final int i) throws SQLException { + final Email email = emails.get(i); + ps.setObject(1, email.getId()); + ps.setObject(2, email.getRequestId()); + ps.setString(3, email.getRecipient1()); + ps.setString(4, email.getRecipient2()); + ps.setString(5, email.getReplyTo()); + ps.setObject(6, email.getTemplateId()); + ps.setObject(7, jsonbObject(jsonb.toJson(email.getTemplateValues()))); + ps.setString( + 8, + email.getStatus() == null + ? EmailStatus.PENDING.name() + : email.getStatus().name()); + } + + @Override + public int getBatchSize() { + return emails.size(); + } + }); + } + + @Override + public List claimBatch(final int limit) { + final String sql = """ + UPDATE emails SET status = 'PROCESSING', updated_at = now() + WHERE id IN ( + SELECT id FROM emails WHERE status = 'PENDING' ORDER BY created_at LIMIT :limit + ) + RETURNING * + """; + return jdbc.sql(sql) + .param("limit", limit) + .query((rs, rowNum) -> parseResultSet(rs)) + .list(); + } + + @Override + public void markSent(final UUID id) { + jdbc.sql("UPDATE emails SET status = 'SENT', sent_at = now(), updated_at = now() WHERE id = :id") + .param("id", id) + .update(); + } + + @Override + public void markError(final UUID id, final String errorMessage) { + jdbc.sql("UPDATE emails SET status = 'ERROR', error_message = :msg, updated_at = now() WHERE id = :id") + .param("id", id) + .param("msg", errorMessage) + .update(); + } + + @Override + public int resetProcessingToError() { + return jdbc.sql(""" + UPDATE emails + SET status = 'ERROR', + error_message = 'Reset on startup: orphaned PROCESSING row (at-most-once recovery)', + updated_at = now() + WHERE status = 'PROCESSING' + """).update(); + } + + @Override + public Map countByStatus(final UUID requestId) { + final Map counts = new EnumMap<>(EmailStatus.class); + jdbc.sql("SELECT status, count(*) AS cnt FROM emails WHERE request_id = :rid GROUP BY status") + .param("rid", requestId) + .query((rs, rowNum) -> { + counts.put(EmailStatus.valueOf(rs.getString("status")), rs.getInt("cnt")); + return null; + }) + .list(); + return counts; + } + + @Override + public List findByRequest(final UUID requestId) { + return jdbc.sql("SELECT * FROM emails WHERE request_id = :rid ORDER BY created_at") + .param("rid", requestId) + .query((rs, rowNum) -> parseResultSet(rs)) + .list(); + } + + @Override + public Optional findById(final UUID id) { + return jdbc.sql("SELECT * FROM emails WHERE id = :id") + .param("id", id) + .query((rs, rowNum) -> parseResultSet(rs)) + .optional(); + } + + @Override + public int markPendingIfError(final UUID id) { + return jdbc.sql(""" + UPDATE emails SET status = 'PENDING', error_message = NULL, updated_at = now() + WHERE id = :id AND status = 'ERROR' + """).param("id", id).update(); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/email/db/repos/EmailTemplateRepo.java b/src/main/java/org/patinanetwork/patchats/email/db/repos/EmailTemplateRepo.java new file mode 100644 index 0000000..b4b592c --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/db/repos/EmailTemplateRepo.java @@ -0,0 +1,32 @@ +package org.patinanetwork.patchats.email.db.repos; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.patinanetwork.patchats.email.db.models.EmailTemplate; + +/** + * Access to {@code email_templates}. Read/list only in Increment 1 (templates are seeded and read-only); create/delete + * arrive in Increment 5. Templates are immutable — there is deliberately no update. + */ +public interface EmailTemplateRepo { + + Optional findById(UUID id); + + List findAll(); + + /** Creates a new template. Returns the generated UUID. */ + UUID create(String name, String subject, String body); + + /** + * Soft-deletes a template (sets {@code deleted_at}). Returns the number of rows updated (0 if not found or already + * deleted). + */ + int softDelete(UUID id); + + /** Checks if a template name is already in use (excluding soft-deleted rows). */ + boolean nameExists(String name); + + /** Counts non-deleted rows that reference this template. Blocks hard-delete if count > 0. */ + long countEmailsReferencing(UUID templateId); +} diff --git a/src/main/java/org/patinanetwork/patchats/email/db/repos/EmailTemplateSqlRepo.java b/src/main/java/org/patinanetwork/patchats/email/db/repos/EmailTemplateSqlRepo.java new file mode 100644 index 0000000..209d1bb --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/db/repos/EmailTemplateSqlRepo.java @@ -0,0 +1,82 @@ +package org.patinanetwork.patchats.email.db.repos; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.patinanetwork.patchats.email.db.models.EmailTemplate; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class EmailTemplateSqlRepo implements EmailTemplateRepo { + + private final JdbcClient jdbc; + + static EmailTemplate parseResultSet(final ResultSet rs) throws SQLException { + return EmailTemplate.builder() + .id(UUID.fromString(rs.getString("id"))) + .name(rs.getString("name")) + .subject(rs.getString("subject")) + .body(rs.getString("body")) + .createdAt(rs.getTimestamp("created_at").toInstant()) + .updatedAt(rs.getTimestamp("updated_at").toInstant()) + .build(); + } + + @Override + public Optional findById(final UUID id) { + return jdbc.sql("SELECT * FROM email_templates WHERE id = :id") + .param("id", id) + .query((rs, rowNum) -> parseResultSet(rs)) + .optional(); + } + + @Override + public List findAll() { + return jdbc.sql("SELECT * FROM email_templates ORDER BY name") + .query((rs, rowNum) -> parseResultSet(rs)) + .list(); + } + + @Override + public UUID create(final String name, final String subject, final String body) { + final UUID id = UUID.randomUUID(); + jdbc.sql( + "INSERT INTO email_templates (id, name, subject, body, created_at, updated_at) VALUES (:id, :name, :subject, :body, NOW(), NOW())") + .param("id", id) + .param("name", name) + .param("subject", subject) + .param("body", body) + .update(); + return id; + } + + @Override + public int softDelete(final UUID id) { + return jdbc.sql("DELETE FROM email_templates WHERE id = :id") + .param("id", id) + .update(); + } + + @Override + public boolean nameExists(final String name) { + final Integer count = jdbc.sql("SELECT COUNT(*) FROM email_templates WHERE name = :name") + .param("name", name) + .query(Integer.class) + .single(); + return count > 0; + } + + @Override + public long countEmailsReferencing(final UUID templateId) { + final Long count = jdbc.sql("SELECT COUNT(*) FROM emails WHERE template_id = :templateId") + .param("templateId", templateId) + .query(Long.class) + .single(); + return count != null ? count : 0; + } +} diff --git a/src/main/java/org/patinanetwork/patchats/email/dto/CreateTemplateRequest.java b/src/main/java/org/patinanetwork/patchats/email/dto/CreateTemplateRequest.java new file mode 100644 index 0000000..7fd8fdf --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/dto/CreateTemplateRequest.java @@ -0,0 +1,14 @@ +package org.patinanetwork.patchats.email.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** Request to create a new email template. All templates follow the ${} placeholder syntax. */ +public record CreateTemplateRequest( + @NotBlank(message = "Template name cannot be blank") + @Size(max = 255, message = "Template name must be 255 characters or less") + String name, + + @NotBlank(message = "Subject cannot be blank") String subject, + + @NotBlank(message = "Body cannot be blank") String body) {} diff --git a/src/main/java/org/patinanetwork/patchats/email/dto/EmailProgressResponse.java b/src/main/java/org/patinanetwork/patchats/email/dto/EmailProgressResponse.java new file mode 100644 index 0000000..c9ea10d --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/dto/EmailProgressResponse.java @@ -0,0 +1,13 @@ +package org.patinanetwork.patchats.email.dto; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +/** Live state of one batch: per-status counts plus the per-email rows. Powers the polled progress view. */ +public record EmailProgressResponse( + int total, int pending, int processing, int sent, int error, List emails) { + + /** One outbox row as shown in the progress table; {@code recipients} merges the two recipient columns. */ + public record EmailSummary(UUID id, List recipients, String status, String error, Instant sentAt) {} +} diff --git a/src/main/java/org/patinanetwork/patchats/email/dto/EmailRequestSummary.java b/src/main/java/org/patinanetwork/patchats/email/dto/EmailRequestSummary.java new file mode 100644 index 0000000..2a5c28a --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/dto/EmailRequestSummary.java @@ -0,0 +1,31 @@ +package org.patinanetwork.patchats.email.dto; + +import java.time.Instant; +import java.util.UUID; +import org.patinanetwork.patchats.email.db.models.EmailRequestCounts; + +/** History-list entry for a past sending session. {@code terminal} is true once nothing is still in flight. */ +public record EmailRequestSummary( + UUID id, + String source, + UUID templateId, + Instant createdAt, + int total, + int sent, + int error, + int inFlight, + boolean terminal) { + + public static EmailRequestSummary from(final EmailRequestCounts counts) { + return new EmailRequestSummary( + counts.id(), + counts.source().name(), + counts.templateId(), + counts.createdAt(), + counts.totalCount(), + counts.sent(), + counts.error(), + counts.inFlight(), + counts.inFlight() == 0); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/email/dto/EmailTemplateResponse.java b/src/main/java/org/patinanetwork/patchats/email/dto/EmailTemplateResponse.java new file mode 100644 index 0000000..717c3b3 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/dto/EmailTemplateResponse.java @@ -0,0 +1,18 @@ +package org.patinanetwork.patchats.email.dto; + +import java.time.Instant; +import java.util.UUID; +import org.patinanetwork.patchats.email.db.models.EmailTemplate; + +/** API view of a template for the read-only list ({@code GET /api/email/templates}) and the template manager. */ +public record EmailTemplateResponse(UUID id, String name, String subject, String body, Instant createdAt) { + + public static EmailTemplateResponse from(final EmailTemplate template) { + return new EmailTemplateResponse( + template.getId(), + template.getName(), + template.getSubject(), + template.getBody(), + template.getCreatedAt()); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/email/dto/EnqueueEmailRequest.java b/src/main/java/org/patinanetwork/patchats/email/dto/EnqueueEmailRequest.java new file mode 100644 index 0000000..6a850f1 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/dto/EnqueueEmailRequest.java @@ -0,0 +1,25 @@ +package org.patinanetwork.patchats.email.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Request to enqueue an async send. Unlike the sync {@link SendEmailRequest}, the subject/body are not supplied inline + * — they come from the referenced {@code templateId}, which the runner renders per row at send-time (decision #4, #16). + */ +public record EnqueueEmailRequest( + @NotNull UUID templateId, + @Email String replyTo, + @NotEmpty @Valid List messages) { + + /** One outgoing email addressed to 1–2 recipients who share the rendered body. */ + public record Message( + Map variables, + @NotEmpty @Size(max = 2) @Valid List recipients) {} +} diff --git a/src/main/java/org/patinanetwork/patchats/email/dto/EnqueueEmailResponse.java b/src/main/java/org/patinanetwork/patchats/email/dto/EnqueueEmailResponse.java new file mode 100644 index 0000000..c46f0ef --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/dto/EnqueueEmailResponse.java @@ -0,0 +1,10 @@ +package org.patinanetwork.patchats.email.dto; + +import java.util.UUID; + +/** + * Result of an enqueue: the parent session id to poll for progress, and how many rows were accepted. {@code requestId} + * is {@code null} when nothing was enqueued (e.g. every matching pair was filtered by the dedup guard), in which case + * {@code accepted} is 0 and there is no session to poll. + */ +public record EnqueueEmailResponse(UUID requestId, int accepted) {} diff --git a/src/main/java/org/patinanetwork/patchats/email/dto/PreviewTemplateRequest.java b/src/main/java/org/patinanetwork/patchats/email/dto/PreviewTemplateRequest.java new file mode 100644 index 0000000..a72c2b1 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/dto/PreviewTemplateRequest.java @@ -0,0 +1,13 @@ +package org.patinanetwork.patchats.email.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.util.UUID; + +/** + * Request to render a stored template against caller-supplied messages without sending. Mirrors the async send input so + * the preview shows exactly what the runner would render (decision #4). + */ +public record PreviewTemplateRequest( + @NotNull UUID templateId, @NotEmpty @Valid java.util.List messages) {} diff --git a/src/test/java/org/patinanetwork/patchats/email/EmailControllerTest.java b/src/test/java/org/patinanetwork/patchats/email/EmailControllerTest.java index 1f143a8..011d2d0 100644 --- a/src/test/java/org/patinanetwork/patchats/email/EmailControllerTest.java +++ b/src/test/java/org/patinanetwork/patchats/email/EmailControllerTest.java @@ -2,13 +2,18 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.time.Instant; import java.util.List; +import java.util.Optional; +import java.util.UUID; import org.junit.jupiter.api.Test; import org.patinanetwork.patchats.common.web.ApiExceptionHandler; +import org.patinanetwork.patchats.email.db.models.EmailTemplate; import org.patinanetwork.patchats.email.dto.SendEmailResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -29,6 +34,21 @@ class EmailControllerTest { @MockitoBean private EmailService emailService; + @MockitoBean + private EmailEnqueueService enqueueService; + + @MockitoBean + private EmailProgressService progressService; + + @MockitoBean + private EmailDrainer drainer; + + @MockitoBean + private org.patinanetwork.patchats.email.db.repos.EmailTemplateRepo templateRepo; + + @MockitoBean + private TemplateManagementService templateManagementService; + @Test void returnsOkAndApiResponderOnSuccess() throws Exception { when(emailService.send(any())) @@ -55,4 +75,41 @@ void returnsBadRequestOnInvalidEmail() throws Exception { .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.success").value(false)); } + + @Test + void createTemplateReturnsCreatedTemplate() throws Exception { + final UUID templateId = UUID.randomUUID(); + when(templateManagementService.createTemplate(any())).thenReturn(templateId); + when(templateRepo.findById(templateId)) + .thenReturn(Optional.of(EmailTemplate.builder() + .id(templateId) + .name("Welcome") + .subject("Hi ${per1.name}") + .body("Body") + .createdAt(Instant.EPOCH) + .build())); + + mockMvc.perform(post("/api/email/templates") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"Welcome\",\"subject\":\"Hi ${per1.name}\",\"body\":\"Body\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.payload.name").value("Welcome")); + } + + @Test + void createTemplateReturnsBadRequestOnBlankName() throws Exception { + mockMvc.perform(post("/api/email/templates") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"\",\"subject\":\"S\",\"body\":\"B\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)); + } + + @Test + void deleteTemplateReturnsAccepted() throws Exception { + mockMvc.perform(delete("/api/email/templates/{id}", UUID.randomUUID())) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.success").value(true)); + } } diff --git a/src/test/java/org/patinanetwork/patchats/email/EmailDrainerTest.java b/src/test/java/org/patinanetwork/patchats/email/EmailDrainerTest.java new file mode 100644 index 0000000..a9161ff --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/email/EmailDrainerTest.java @@ -0,0 +1,157 @@ +package org.patinanetwork.patchats.email; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.Executor; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.patinanetwork.patchats.email.db.models.Email; +import org.patinanetwork.patchats.email.db.models.EmailStatus; +import org.patinanetwork.patchats.email.db.models.EmailTemplate; +import org.patinanetwork.patchats.email.db.repos.EmailRepo; +import org.patinanetwork.patchats.email.db.repos.EmailTemplateRepo; +import org.springframework.mail.MailSendException; + +class EmailDrainerTest { + + private static final UUID TEMPLATE_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + + private final EmailRepo emailRepo = mock(EmailRepo.class); + private final EmailTemplateRepo templateRepo = mock(EmailTemplateRepo.class); + private final EmailSender sender = mock(EmailSender.class); + private final EmailRenderer renderer = new EmailRenderer(new TemplateRenderer()); + + /** Runs submitted jobs inline on the calling thread, so a drain completes synchronously within trigger(). */ + private static final Executor SYNC = Runnable::run; + + private EmailDrainer drainer(final Executor executor) { + return new EmailDrainer(emailRepo, templateRepo, renderer, sender, executor); + } + + private EmailTemplate template(final String subject, final String body) { + return EmailTemplate.builder() + .id(TEMPLATE_ID) + .name("t") + .subject(subject) + .body(body) + .createdAt(Instant.now()) + .updatedAt(Instant.now()) + .build(); + } + + private Email email(final Map values, final String recipient2) { + return Email.builder() + .id(UUID.randomUUID()) + .requestId(UUID.randomUUID()) + .recipient1("ann@x.com") + .recipient2(recipient2) + .templateId(TEMPLATE_ID) + .templateValues(values) + .status(EmailStatus.PROCESSING) + .build(); + } + + @Test + void rendersEachRowFromItsTemplateThenMarksSent() { + final Email row = email(Map.of("per1.name", "Ann"), null); + when(emailRepo.claimBatch(50)).thenReturn(List.of(row), List.of()); + when(templateRepo.findById(TEMPLATE_ID)) + .thenReturn(Optional.of(template("Hi ${per1.name}", "Body ${per1.name}"))); + + drainer(SYNC).trigger(); + + final ArgumentCaptor sent = ArgumentCaptor.forClass(OutgoingEmail.class); + verify(sender).send(sent.capture()); + assertEquals("Hi Ann", sent.getValue().subject()); + assertEquals("Body Ann", sent.getValue().body()); + assertEquals(List.of("ann@x.com"), sent.getValue().to()); + verify(emailRepo).markSent(row.getId()); + verify(emailRepo, never()).markError(any(), any()); + verify(emailRepo, atLeastOnce()).claimBatch(50); // claims in batches of ≤50 + } + + @Test + void pairRowSendsToBothRecipients() { + final Email row = email(Map.of("per1.name", "Ann", "per2.name", "Bob"), "bob@x.com"); + when(emailRepo.claimBatch(50)).thenReturn(List.of(row), List.of()); + when(templateRepo.findById(TEMPLATE_ID)) + .thenReturn(Optional.of(template("Hi ${per1.name} & ${per2.name}", "b"))); + + drainer(SYNC).trigger(); + + final ArgumentCaptor sent = ArgumentCaptor.forClass(OutgoingEmail.class); + verify(sender).send(sent.capture()); + assertEquals(List.of("ann@x.com", "bob@x.com"), sent.getValue().to()); + assertEquals("Hi Ann & Bob", sent.getValue().subject()); + } + + @Test + void sendFailureIsTerminalErrorWithNoRetry() { + final Email row = email(Map.of("per1.name", "Ann"), null); + when(emailRepo.claimBatch(50)).thenReturn(List.of(row), List.of()); + when(templateRepo.findById(TEMPLATE_ID)).thenReturn(Optional.of(template("Hi ${per1.name}", "b"))); + doThrow(new MailSendException("smtp down")).when(sender).send(any()); + + drainer(SYNC).trigger(); + + verify(sender, times(1)).send(any()); // one attempt only + verify(emailRepo).markError(eq(row.getId()), any()); + verify(emailRepo, never()).markSent(any()); + } + + @Test + void renderFailureIsTerminalError() { + // Required ${per1.name} has no value → renderer throws → row goes straight to ERROR, nothing sent. + final Email row = email(Map.of(), null); + when(emailRepo.claimBatch(50)).thenReturn(List.of(row), List.of()); + when(templateRepo.findById(TEMPLATE_ID)).thenReturn(Optional.of(template("Hi ${per1.name}", "b"))); + + drainer(SYNC).trigger(); + + verify(sender, never()).send(any()); + verify(emailRepo).markError(eq(row.getId()), any()); + verify(emailRepo, never()).markSent(any()); + } + + @Test + void startupResetsProcessingToErrorThenDrains() { + when(emailRepo.resetProcessingToError()).thenReturn(2); + when(emailRepo.claimBatch(50)).thenReturn(List.of()); + + drainer(SYNC).onApplicationReady(); + + verify(emailRepo).resetProcessingToError(); + verify(emailRepo).claimBatch(50); // the startup kick still runs a drain pass + } + + @Test + void overlappingTriggersCoalesceToOneDrain() { + // A manual executor that captures jobs without running them, to observe submission count. + final List submitted = new ArrayList<>(); + final Executor capturing = submitted::add; + when(emailRepo.claimBatch(50)).thenReturn(List.of()); + final EmailDrainer drainer = drainer(capturing); + + drainer.trigger(); // running := true, one job submitted + drainer.trigger(); // running already true → no second submission, rerun flagged + assertEquals(1, submitted.size()); + + submitted.get(0).run(); // drain runs, honours the rerun flag internally, then clears running + assertEquals(1, submitted.size()); // still exactly one drain submission + } +} diff --git a/src/test/java/org/patinanetwork/patchats/email/EmailEnqueueServiceTest.java b/src/test/java/org/patinanetwork/patchats/email/EmailEnqueueServiceTest.java new file mode 100644 index 0000000..ab05e75 --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/email/EmailEnqueueServiceTest.java @@ -0,0 +1,121 @@ +package org.patinanetwork.patchats.email; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.patinanetwork.patchats.common.web.exception.EmailTemplateNotFoundException; +import org.patinanetwork.patchats.email.db.models.Email; +import org.patinanetwork.patchats.email.db.models.EmailRequest; +import org.patinanetwork.patchats.email.db.models.EmailSource; +import org.patinanetwork.patchats.email.db.models.EmailStatus; +import org.patinanetwork.patchats.email.db.models.EmailTemplate; +import org.patinanetwork.patchats.email.db.repos.EmailRepo; +import org.patinanetwork.patchats.email.db.repos.EmailRequestRepo; +import org.patinanetwork.patchats.email.db.repos.EmailTemplateRepo; +import org.patinanetwork.patchats.email.dto.EnqueueEmailRequest; +import org.patinanetwork.patchats.email.dto.EnqueueEmailResponse; +import org.patinanetwork.patchats.email.dto.SendEmailRequest; + +class EmailEnqueueServiceTest { + + private final EmailTemplateRepo templateRepo = mock(EmailTemplateRepo.class); + private final EmailRequestRepo requestRepo = mock(EmailRequestRepo.class); + private final EmailRepo emailRepo = mock(EmailRepo.class); + private final EmailEnqueueService service = + new EmailEnqueueService(templateRepo, requestRepo, emailRepo, new EmailRenderer(new TemplateRenderer())); + + private static final UUID TEMPLATE_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + + private EmailTemplate seededTemplate() { + return EmailTemplate.builder() + .id(TEMPLATE_ID) + .name("Welcome") + .subject("Hi ${per1.name}") + .body("Body for ${per1.name}") + .createdAt(Instant.now()) + .updatedAt(Instant.now()) + .build(); + } + + @SuppressWarnings("unchecked") + private List captureInsertedEmails() { + final ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(emailRepo).insertAll(captor.capture()); + return captor.getValue(); + } + + @Test + void enqueueStoresTemplateRefAndValuesWithoutRendering() { + when(templateRepo.findById(TEMPLATE_ID)).thenReturn(Optional.of(seededTemplate())); + + final EnqueueEmailRequest request = new EnqueueEmailRequest( + TEMPLATE_ID, + "reply@x.com", + List.of(new EnqueueEmailRequest.Message( + Map.of("period", "July"), + List.of(new SendEmailRequest.Recipient("ann@x.com", Map.of("name", "Ann")))))); + + final EnqueueEmailResponse response = service.enqueue(request, EmailSource.MANUAL); + + assertEquals(1, response.accepted()); + final List inserted = captureInsertedEmails(); + assertEquals(1, inserted.size()); + final Email email = inserted.get(0); + assertEquals(EmailStatus.PENDING, email.getStatus()); + assertEquals(TEMPLATE_ID, email.getTemplateId()); + assertEquals("ann@x.com", email.getRecipient1()); + assertNull(email.getRecipient2()); + // Stores the merged variables, not any rendered subject/body. + assertEquals("Ann", email.getTemplateValues().get("per1.name")); + assertEquals("July", email.getTemplateValues().get("period")); + } + + @Test + void enqueueInsertsOneParentAndNChildren() { + when(templateRepo.findById(TEMPLATE_ID)).thenReturn(Optional.of(seededTemplate())); + + final EnqueueEmailRequest request = new EnqueueEmailRequest( + TEMPLATE_ID, + null, + List.of( + new EnqueueEmailRequest.Message( + Map.of(), List.of(new SendEmailRequest.Recipient("a@x.com", Map.of()))), + new EnqueueEmailRequest.Message( + Map.of(), List.of(new SendEmailRequest.Recipient("b@x.com", Map.of()))))); + + service.enqueue(request, EmailSource.MANUAL); + + final ArgumentCaptor parent = ArgumentCaptor.forClass(EmailRequest.class); + verify(requestRepo).insert(parent.capture()); + assertEquals(2, parent.getValue().getTotalCount()); + assertEquals(EmailSource.MANUAL, parent.getValue().getSource()); + assertEquals(2, captureInsertedEmails().size()); + } + + @Test + void unknownTemplateThrows() { + final UUID missing = UUID.randomUUID(); + when(templateRepo.findById(missing)).thenReturn(Optional.empty()); + + final EnqueueEmailRequest request = new EnqueueEmailRequest( + missing, + null, + List.of(new EnqueueEmailRequest.Message( + Map.of(), List.of(new SendEmailRequest.Recipient("a@x.com", Map.of()))))); + + assertThrows(EmailTemplateNotFoundException.class, () -> service.enqueue(request, EmailSource.MANUAL)); + verify(emailRepo, org.mockito.Mockito.never()).insertAll(any()); + } +} diff --git a/src/test/java/org/patinanetwork/patchats/email/EmailProgressServiceTest.java b/src/test/java/org/patinanetwork/patchats/email/EmailProgressServiceTest.java new file mode 100644 index 0000000..4a69d5a --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/email/EmailProgressServiceTest.java @@ -0,0 +1,115 @@ +package org.patinanetwork.patchats.email; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.patinanetwork.patchats.common.web.exception.EmailNotFoundException; +import org.patinanetwork.patchats.common.web.exception.EmailNotResendableException; +import org.patinanetwork.patchats.email.db.models.Email; +import org.patinanetwork.patchats.email.db.models.EmailRequestCounts; +import org.patinanetwork.patchats.email.db.models.EmailSource; +import org.patinanetwork.patchats.email.db.models.EmailStatus; +import org.patinanetwork.patchats.email.db.repos.EmailRepo; +import org.patinanetwork.patchats.email.db.repos.EmailRequestRepo; +import org.patinanetwork.patchats.email.dto.EmailProgressResponse; +import org.patinanetwork.patchats.email.dto.EmailRequestSummary; + +class EmailProgressServiceTest { + + private final EmailRepo emailRepo = mock(EmailRepo.class); + private final EmailRequestRepo requestRepo = mock(EmailRequestRepo.class); + private final EmailDrainer drainer = mock(EmailDrainer.class); + private final EmailProgressService service = new EmailProgressService(emailRepo, requestRepo, drainer); + + private Email row(final EmailStatus status, final String recipient2) { + return Email.builder() + .id(UUID.randomUUID()) + .requestId(UUID.randomUUID()) + .recipient1("a@x.com") + .recipient2(recipient2) + .templateId(UUID.randomUUID()) + .templateValues(Map.of()) + .status(status) + .build(); + } + + @Test + void progressAggregatesCountsAndMergesRecipients() { + final UUID requestId = UUID.randomUUID(); + when(emailRepo.countByStatus(requestId)) + .thenReturn(Map.of(EmailStatus.SENT, 2, EmailStatus.ERROR, 1, EmailStatus.PENDING, 1)); + when(emailRepo.findByRequest(requestId)).thenReturn(List.of(row(EmailStatus.SENT, "b@x.com"))); + + final EmailProgressResponse response = service.progress(requestId); + + assertEquals(4, response.total()); + assertEquals(2, response.sent()); + assertEquals(1, response.error()); + assertEquals(1, response.pending()); + assertEquals(0, response.processing()); + assertEquals(List.of("a@x.com", "b@x.com"), response.emails().get(0).recipients()); + } + + @Test + void historyMarksSessionTerminalWhenNothingInFlight() { + when(requestRepo.listWithCounts()) + .thenReturn(List.of( + new EmailRequestCounts( + UUID.randomUUID(), EmailSource.MANUAL, UUID.randomUUID(), Instant.now(), 3, 3, 0, 0), + new EmailRequestCounts( + UUID.randomUUID(), + EmailSource.MATCHING, + UUID.randomUUID(), + Instant.now(), + 5, + 2, + 0, + 3))); + + final List history = service.history(); + + assertTrue(history.get(0).terminal()); + assertEquals(false, history.get(1).terminal()); + } + + @Test + void resendFlipsErrorRowAndTriggersDrain() { + final UUID id = UUID.randomUUID(); + when(emailRepo.markPendingIfError(id)).thenReturn(1); + + service.resend(id); + + verify(drainer).trigger(); + } + + @Test + void resendUnknownRowIs404() { + final UUID id = UUID.randomUUID(); + when(emailRepo.markPendingIfError(id)).thenReturn(0); + when(emailRepo.findById(id)).thenReturn(Optional.empty()); + + assertThrows(EmailNotFoundException.class, () -> service.resend(id)); + verify(drainer, never()).trigger(); + } + + @Test + void resendNonErrorRowIs409() { + final UUID id = UUID.randomUUID(); + when(emailRepo.markPendingIfError(id)).thenReturn(0); + when(emailRepo.findById(id)).thenReturn(Optional.of(row(EmailStatus.SENT, null))); + + assertThrows(EmailNotResendableException.class, () -> service.resend(id)); + verify(drainer, never()).trigger(); + } +}