diff --git a/src/main/java/org/patinanetwork/patchats/email/EmailDrainer.java b/src/main/java/org/patinanetwork/patchats/email/EmailDrainer.java
index b1e29b8a..d602f0d6 100644
--- a/src/main/java/org/patinanetwork/patchats/email/EmailDrainer.java
+++ b/src/main/java/org/patinanetwork/patchats/email/EmailDrainer.java
@@ -16,8 +16,9 @@
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).
+ * On-demand background runner (decision #6). Drains the {@code emails} outbox: claims a small batch (decision #7),
+ * 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.
@@ -26,13 +27,12 @@
@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;
+ private final EmailProperties properties;
/** True while a drain job is running; guards against launching a second overlapping drain. */
private final AtomicBoolean running = new AtomicBoolean(false);
@@ -44,12 +44,14 @@ public EmailDrainer(
final EmailTemplateRepo templateRepo,
final EmailRenderer renderer,
final EmailSender sender,
- @Qualifier("emailDrainExecutor") final Executor executor) {
+ @Qualifier("emailDrainExecutor") final Executor executor,
+ final EmailProperties properties) {
this.emailRepo = emailRepo;
this.templateRepo = templateRepo;
this.renderer = renderer;
this.sender = sender;
this.executor = executor;
+ this.properties = properties;
}
/**
@@ -81,12 +83,12 @@ private void 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);
+ List batch = emailRepo.claimBatch(properties.getDrainBatchSize());
while (!batch.isEmpty()) {
for (final Email email : batch) {
sendOne(email, templateCache);
}
- batch = emailRepo.claimBatch(BATCH_SIZE);
+ batch = emailRepo.claimBatch(properties.getDrainBatchSize());
}
}
diff --git a/src/main/java/org/patinanetwork/patchats/email/EmailProperties.java b/src/main/java/org/patinanetwork/patchats/email/EmailProperties.java
index 7518e85f..89fecadf 100644
--- a/src/main/java/org/patinanetwork/patchats/email/EmailProperties.java
+++ b/src/main/java/org/patinanetwork/patchats/email/EmailProperties.java
@@ -1,11 +1,14 @@
package org.patinanetwork.patchats.email;
+import jakarta.validation.constraints.Min;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.validation.annotation.Validated;
/** Email sender configuration, bound from {@code app.email.*}. */
@ConfigurationProperties(prefix = "app.email")
+@Validated
@Getter
@Setter
public class EmailProperties {
@@ -16,6 +19,10 @@ public class EmailProperties {
/** Optional display name shown alongside the From address. */
private String fromName;
+ /** The amount of emails sent out per batch (decision #7). */
+ @Min(1)
+ private int drainBatchSize = 50;
+
/** Builds the From header: {@code "Name "} when a display name is set, else the bare address. */
public String getFromHeader() {
if (fromName == null || fromName.isBlank()) {
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 9121e7be..1d19d4ca 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -32,6 +32,7 @@ app:
email:
from: ${EMAIL_FROM:coffeechats@patinanetwork.org}
from-name: ${EMAIL_FROM_NAME:PatChats}
+ drain-batch-size: 50
auth:
# Public origin of the SPA; magic links point at /auth/verify?token=...
base-url: ${APP_BASE_URL:http://patchats.patinanetwork.org}
diff --git a/src/test/java/org/patinanetwork/patchats/email/EmailDrainerTest.java b/src/test/java/org/patinanetwork/patchats/email/EmailDrainerTest.java
index efa9e19b..db9c091c 100644
--- a/src/test/java/org/patinanetwork/patchats/email/EmailDrainerTest.java
+++ b/src/test/java/org/patinanetwork/patchats/email/EmailDrainerTest.java
@@ -35,12 +35,13 @@ class EmailDrainerTest {
private final EmailTemplateRepo templateRepo = mock(EmailTemplateRepo.class);
private final EmailSender sender = mock(EmailSender.class);
private final EmailRenderer renderer = new EmailRenderer(new TemplateRenderer());
+ private final EmailProperties properties = new EmailProperties();
/** 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);
+ return new EmailDrainer(emailRepo, templateRepo, renderer, sender, executor, properties);
}
private EmailTemplate template(final String subject, final String body) {
@@ -128,6 +129,26 @@ void renderFailureIsTerminalError() {
verify(emailRepo, never()).markSent(any());
}
+ @Test
+ void claimsBatchesOfTheConfiguredSize() {
+ properties.setDrainBatchSize(2);
+ final Email ann = email(Map.of("per1.name", "Ann"), null);
+ final Email bob = email(Map.of("per1.name", "Bob"), null);
+ final Email cy = email(Map.of("per1.name", "Cy"), null);
+ when(emailRepo.claimBatch(2)).thenReturn(List.of(ann, bob), List.of(cy), List.of());
+ when(templateRepo.findById(TEMPLATE_ID)).thenReturn(Optional.of(template("Hi ${per1.name}", "b")));
+
+ drainer(SYNC).trigger();
+
+ final ArgumentCaptor limits = ArgumentCaptor.forClass(Integer.class);
+ verify(emailRepo, atLeastOnce()).claimBatch(limits.capture());
+ // A full batch, then the remainder, then the empty claim that ends the drain.
+ assertEquals(List.of(2, 2, 2), limits.getAllValues());
+ verify(emailRepo).markSent(ann.getId());
+ verify(emailRepo).markSent(bob.getId());
+ verify(emailRepo).markSent(cy.getId());
+ }
+
@Test
void overlappingTriggersCoalesceToOneDrain() {
// A manual executor that captures jobs without running them, to observe submission count.