Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions src/main/java/org/patinanetwork/patchats/email/EmailDrainer.java
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*
* <p>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.
Expand All @@ -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);
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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<UUID, EmailTemplate> templateCache = new HashMap<>();
List<Email> batch = emailRepo.claimBatch(BATCH_SIZE);
List<Email> 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());
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 <addr>"} when a display name is set, else the bare address. */
public String getFromHeader() {
if (fromName == null || fromName.isBlank()) {
Expand Down
1 change: 1 addition & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <base-url>/auth/verify?token=...
base-url: ${APP_BASE_URL:http://patchats.patinanetwork.org}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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<Integer> 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.
Expand Down
Loading