> previewLegacy(
- @Valid @RequestBody final SendEmailRequest request) {
- final PreviewEmailResponse response = emailService.preview(request);
- return ResponseEntity.ok(ApiResponder.success(
- "Rendered %d emails".formatted(response.previews().size()), response));
- }
-}
diff --git a/src/main/java/org/patinanetwork/patchats/email/EmailDrainer.java b/src/main/java/org/patinanetwork/patchats/email/EmailDrainer.java
deleted file mode 100644
index 76946f2..0000000
--- a/src/main/java/org/patinanetwork/patchats/email/EmailDrainer.java
+++ /dev/null
@@ -1,127 +0,0 @@
-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
deleted file mode 100644
index 0885922..0000000
--- a/src/main/java/org/patinanetwork/patchats/email/EmailEnqueueService.java
+++ /dev/null
@@ -1,119 +0,0 @@
-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
deleted file mode 100644
index 07554ec..0000000
--- a/src/main/java/org/patinanetwork/patchats/email/EmailExecutorConfig.java
+++ /dev/null
@@ -1,28 +0,0 @@
-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
deleted file mode 100644
index 5cb9fc0..0000000
--- a/src/main/java/org/patinanetwork/patchats/email/EmailProgressService.java
+++ /dev/null
@@ -1,68 +0,0 @@
-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
deleted file mode 100644
index 28fae08..0000000
--- a/src/main/java/org/patinanetwork/patchats/email/EmailRenderer.java
+++ /dev/null
@@ -1,27 +0,0 @@
-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
deleted file mode 100644
index de9c459..0000000
--- a/src/main/java/org/patinanetwork/patchats/email/EmailService.java
+++ /dev/null
@@ -1,110 +0,0 @@
-package org.patinanetwork.patchats.email;
-
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.patinanetwork.patchats.email.dto.PreviewEmailResponse;
-import org.patinanetwork.patchats.email.dto.SendEmailRequest;
-import org.patinanetwork.patchats.email.dto.SendEmailResponse;
-import org.springframework.stereotype.Service;
-import org.springframework.util.StringUtils;
-
-/**
- * Renders the caller-supplied templates once per message and delivers each via the configured {@link EmailSender}.
- * Best-effort: a render or delivery failure fails only that message and is reported in the result; the rest of the
- * batch still goes out.
- */
-@Service
-@RequiredArgsConstructor
-@Slf4j
-public class EmailService {
-
- private final TemplateRenderer renderer;
- private final EmailSender sender;
-
- public SendEmailResponse send(final SendEmailRequest request) {
- final Optional replyTo = Optional.ofNullable(request.replyTo()).filter(StringUtils::hasText);
- final List results = new ArrayList<>();
- int sent = 0;
- int failed = 0;
-
- for (final SendEmailRequest.Message message : request.messages()) {
- final List recipients = message.recipients().stream()
- .map(SendEmailRequest.Recipient::email)
- .toList();
- try {
- 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));
- log.info("Sent email to {}", recipients);
- results.add(new SendEmailResponse.MessageResult(recipients, true, null));
- sent++;
- } catch (final RuntimeException ex) {
- log.warn("Failed to send email to {}: {}", recipients, ex.getMessage());
- results.add(new SendEmailResponse.MessageResult(recipients, false, ex.getMessage()));
- failed++;
- }
- }
-
- return new SendEmailResponse(sent, failed, results);
- }
-
- /**
- * Renders each message and returns the result without sending. Best-effort: a render failure is reported per
- * message.
- */
- public PreviewEmailResponse preview(final SendEmailRequest request) {
- final List previews = new ArrayList<>();
- for (final SendEmailRequest.Message message : request.messages()) {
- final List recipients = message.recipients().stream()
- .map(SendEmailRequest.Recipient::email)
- .toList();
- try {
- 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));
- } catch (final RuntimeException ex) {
- previews.add(new PreviewEmailResponse.MessagePreview(recipients, null, null, ex.getMessage()));
- }
- }
- return new PreviewEmailResponse(previews);
- }
-
- /**
- * 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. Shared by the sync send/preview paths and the async
- * enqueue pipeline so the stored {@code template_values} match exactly what a preview renders.
- */
- public static Map mergeVariables(
- final Map messageVariables, final List recipients) {
- final Map merged = new HashMap<>();
- if (messageVariables != null) {
- merged.putAll(messageVariables);
- }
- 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
deleted file mode 100644
index 23f1340..0000000
--- a/src/main/java/org/patinanetwork/patchats/email/TemplateManagementService.java
+++ /dev/null
@@ -1,100 +0,0 @@
-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
deleted file mode 100644
index 878e809..0000000
--- a/src/main/java/org/patinanetwork/patchats/email/db/JsonbConverter.java
+++ /dev/null
@@ -1,41 +0,0 @@
-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