diff --git a/js/src/features/emails/EmailSendPage.tsx b/js/src/features/emails/EmailSendPage.tsx
index 928b3d33..77d1c7b1 100644
--- a/js/src/features/emails/EmailSendPage.tsx
+++ b/js/src/features/emails/EmailSendPage.tsx
@@ -6,6 +6,7 @@ import type {
import { CsvUploader } from "@/features/emails/_components/CsvUploader";
import { EmailPreviewer } from "@/features/emails/_components/EmailPreviewer";
import { EmailSender } from "@/features/emails/_components/EmailSender";
+import { SyncEmailSender } from "@/features/emails/_components/SyncEmailSender";
import { TemplateSelector } from "@/features/emails/_components/TemplateSelector";
import { Box, Flex, Stack } from "@mantine/core";
import { useState } from "react";
@@ -37,7 +38,7 @@ export function EmailSendPage() {
/>
{/* CSV uploader */}
- {/* Send button */}
+ {/* Send Async button */}
+ {/* Send Synchronous button */}
+
{/* Preview */}
diff --git a/js/src/features/emails/_components/EmailSender.tsx b/js/src/features/emails/_components/EmailSender.tsx
index 7101f985..5f118966 100644
--- a/js/src/features/emails/_components/EmailSender.tsx
+++ b/js/src/features/emails/_components/EmailSender.tsx
@@ -42,11 +42,11 @@ export function EmailSender({
const openModal = () =>
modals.openConfirmModal({
- title: "Email Send Confirmation",
+ title: "ASYNCHRONOUS Email Send Confirmation",
children: (
Please confirm that you want to send {request?.messages.length} email
- {request?.messages.length === 1 ? "" : "s"}.
+ {request?.messages.length === 1 ? "" : "s"} asynchronously.
),
labels: { confirm: "Confirm", cancel: "Cancel" },
@@ -101,7 +101,7 @@ export function EmailSender({
loading={isSending}
fullWidth
>
- Send Emails
+ Send Asynchronous Emails
);
}
diff --git a/js/src/features/emails/_components/SyncEmailSender.tsx b/js/src/features/emails/_components/SyncEmailSender.tsx
new file mode 100644
index 00000000..abc46ad3
--- /dev/null
+++ b/js/src/features/emails/_components/SyncEmailSender.tsx
@@ -0,0 +1,138 @@
+import type {
+ SendAsyncRequest,
+ SendRequest,
+ EmailTemplate,
+} from "@/features/emails/dto/emailDto";
+
+import { listTemplates, sendToEmailApi } from "@/features/emails/api/emailAPI";
+import {
+ showEmailError,
+ showEmailPending,
+ showEmailSuccess,
+} from "@/features/emails/api/emailError";
+import { Button, Flex, Text } from "@mantine/core";
+import { modals } from "@mantine/modals";
+import { useMutation } from "@tanstack/react-query";
+import { useEffect, useState } from "react";
+
+export function SyncEmailSender({
+ request,
+}: {
+ request: SendAsyncRequest | null;
+}) {
+ const [template, setTemplate] = useState(null);
+
+ const mutation = useMutation({
+ mutationFn: async (req: SendRequest) => sendToEmailApi(req),
+ });
+
+ useEffect(() => {
+ if (!request?.templateId) {
+ setTemplate(null);
+ return;
+ }
+
+ let cancelled = false;
+
+ const loadTemplate = async () => {
+ try {
+ const templates = await listTemplates();
+ const found = templates.find((t) => t.id === request.templateId);
+ if (!cancelled) {
+ setTemplate(found ?? null);
+ }
+ } catch {
+ if (!cancelled) {
+ setTemplate(null);
+ }
+ }
+ };
+
+ void loadTemplate();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [request?.templateId]);
+
+ useEffect(() => {
+ if (mutation.status === "pending") {
+ showEmailPending("pending", "Email sending in progress...");
+ }
+ }, [mutation.status]);
+
+ const openModal = () =>
+ modals.openConfirmModal({
+ title: "SYNCHRONOUS Email Send Confirmation",
+ children: (
+
+ Please confirm that you want to send {request?.messages.length} email
+ {request?.messages.length === 1 ? "" : "s"} synchronously.
+
+ ),
+ labels: { confirm: "Confirm", cancel: "Cancel" },
+ onCancel: () =>
+ showEmailPending(
+ "Cancel",
+ `${request?.messages.length} Emails cancelled.`,
+ ),
+ onConfirm: () => void handleSend(),
+ });
+
+ const handleSend = async () => {
+ if (!request) {
+ showEmailError("Preview first", "Preview before sending.");
+ return;
+ }
+
+ if (!template) {
+ showEmailError(
+ "Template missing",
+ "Could not load the template subject/body.",
+ );
+ return;
+ }
+ // Convert SendAsyncRequest to SendRequest
+ const syncRequest: SendRequest = {
+ templateId: request.templateId,
+ subject: template.subject,
+ body: template.body,
+ replyTo: request.replyTo ?? null,
+ messages: request.messages,
+ };
+
+ try {
+ const response = await mutation.mutateAsync(syncRequest);
+ const payload = response?.payload;
+ const sent = payload?.sent ?? 0;
+ const failed = payload?.failed ?? 0;
+
+ if (failed > 0) {
+ showEmailError(
+ "Partial Success",
+ `Sent ${sent} of ${sent + failed} emails`,
+ );
+ } else {
+ showEmailSuccess(
+ "Success",
+ `All ${sent} email${sent === 1 ? "" : "s"} sent.`,
+ );
+ }
+ } catch {
+ showEmailError("Error", "Unable to send emails.");
+ }
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/js/src/features/emails/dto/emailDto.ts b/js/src/features/emails/dto/emailDto.ts
index 917cafd2..58a5c836 100644
--- a/js/src/features/emails/dto/emailDto.ts
+++ b/js/src/features/emails/dto/emailDto.ts
@@ -27,6 +27,7 @@ export interface MessagePreview {
}
export interface SendRequest {
+ templateId: string;
subject: string;
body: string;
replyTo: string | null;
diff --git a/src/main/java/org/patinanetwork/patchats/email/EmailController.java b/src/main/java/org/patinanetwork/patchats/email/EmailController.java
index 84db9645..503ba5e5 100644
--- a/src/main/java/org/patinanetwork/patchats/email/EmailController.java
+++ b/src/main/java/org/patinanetwork/patchats/email/EmailController.java
@@ -60,7 +60,7 @@ public ResponseEntity> send(@Valid @RequestBody
@PostMapping("/send/async")
public ResponseEntity> sendAsync(
@Valid @RequestBody final EnqueueEmailRequest request) {
- final EnqueueEmailResponse response = enqueueService.enqueue(request, EmailSource.MANUAL);
+ final EnqueueEmailResponse response = enqueueService.enqueue(request, EmailSource.ASYNCHRONOUS);
return ResponseEntity.status(HttpStatus.ACCEPTED)
.body(ApiResponder.success("Accepted %d emails".formatted(response.accepted()), response));
}
diff --git a/src/main/java/org/patinanetwork/patchats/email/EmailService.java b/src/main/java/org/patinanetwork/patchats/email/EmailService.java
index de9c459d..f588d93f 100644
--- a/src/main/java/org/patinanetwork/patchats/email/EmailService.java
+++ b/src/main/java/org/patinanetwork/patchats/email/EmailService.java
@@ -1,12 +1,26 @@
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.HashMap;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.Optional;
+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.PreviewEmailResponse;
import org.patinanetwork.patchats.email.dto.SendEmailRequest;
import org.patinanetwork.patchats.email.dto.SendEmailResponse;
@@ -22,24 +36,35 @@
@RequiredArgsConstructor
@Slf4j
public class EmailService {
-
- private final TemplateRenderer renderer;
+ private final EmailTemplateRepo templateRepo;
+ private final EmailRequestRepo requestRepo;
+ private final EmailRepo emailRepo;
+ private final TemplateRenderer templateRenderer;
private final EmailSender sender;
public SendEmailResponse send(final SendEmailRequest request) {
+ final EmailTemplate template = templateRepo
+ .findById(request.templateId())
+ .orElseThrow(() -> new EmailTemplateNotFoundException(request.templateId()));
+
final Optional replyTo = Optional.ofNullable(request.replyTo()).filter(StringUtils::hasText);
final List results = new ArrayList<>();
int sent = 0;
int failed = 0;
+ // Fill in the send-time month once for the whole batch so ${month} resolves consistently.
+ final String currentMonth =
+ LocalDate.now(ZoneId.of("America/New_York")).getMonth().getDisplayName(TextStyle.FULL, Locale.ENGLISH);
+
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);
+ variables.putIfAbsent("month", currentMonth);
+ final String subject = templateRenderer.render(request.subject(), variables);
+ final String body = templateRenderer.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));
@@ -50,7 +75,40 @@ public SendEmailResponse send(final SendEmailRequest request) {
failed++;
}
}
+ try {
+ final UUID requestId = UUID.randomUUID();
+ requestRepo.insert(EmailRequest.builder()
+ .id(requestId)
+ .source(EmailSource.SYNCHRONOUS)
+ .templateId(template.getId())
+ .totalCount(request.messages().size())
+ .build());
+ // Build email records with the same month variable used in sending
+ final List emails = new ArrayList<>(request.messages().size());
+ for (int i = 0; i < request.messages().size(); i++) {
+ final SendEmailRequest.Message message = request.messages().get(i);
+ final Map variables =
+ EmailService.mergeVariables(message.variables(), message.recipients());
+ variables.putIfAbsent("month", currentMonth);
+ final List recipients = message.recipients();
+ final SendEmailResponse.MessageResult result = results.get(i);
+ 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(result.sent() ? EmailStatus.SENT : EmailStatus.ERROR)
+ .errorMessage(result.error())
+ .build());
+ }
+ emailRepo.insertAll(emails);
+ } catch (Exception ex) {
+ log.error("Failed to insert emails: {}", ex.getMessage());
+ }
return new SendEmailResponse(sent, failed, results);
}
@@ -66,8 +124,8 @@ public PreviewEmailResponse preview(final SendEmailRequest request) {
.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);
+ final String subject = templateRenderer.render(request.subject(), variables);
+ final String body = templateRenderer.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()));
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
index c0728b32..269ad23d 100644
--- a/src/main/java/org/patinanetwork/patchats/email/db/models/EmailSource.java
+++ b/src/main/java/org/patinanetwork/patchats/email/db/models/EmailSource.java
@@ -3,5 +3,7 @@
/** Which producer enqueued a sending session. */
public enum EmailSource {
MANUAL,
- MATCHING
+ MATCHING,
+ SYNCHRONOUS,
+ ASYNCHRONOUS,
}
diff --git a/src/main/java/org/patinanetwork/patchats/email/dto/SendEmailRequest.java b/src/main/java/org/patinanetwork/patchats/email/dto/SendEmailRequest.java
index 79c293cd..ca0d4ce1 100644
--- a/src/main/java/org/patinanetwork/patchats/email/dto/SendEmailRequest.java
+++ b/src/main/java/org/patinanetwork/patchats/email/dto/SendEmailRequest.java
@@ -4,15 +4,18 @@
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
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 send one or more templated plain-text emails. {@code subject} and {@code body} are templates shared across
* all messages; each message supplies the variables to merge in.
*/
public record SendEmailRequest(
+ @NotNull UUID templateId,
@NotBlank String subject,
@NotBlank String body,
@Email String replyTo,
diff --git a/src/test/java/org/patinanetwork/patchats/email/EmailControllerTest.java b/src/test/java/org/patinanetwork/patchats/email/EmailControllerTest.java
index 011d2d09..909a17b0 100644
--- a/src/test/java/org/patinanetwork/patchats/email/EmailControllerTest.java
+++ b/src/test/java/org/patinanetwork/patchats/email/EmailControllerTest.java
@@ -51,6 +51,7 @@ class EmailControllerTest {
@Test
void returnsOkAndApiResponderOnSuccess() throws Exception {
+ final UUID templateId = UUID.randomUUID();
when(emailService.send(any()))
.thenReturn(new SendEmailResponse(
1, 0, List.of(new SendEmailResponse.MessageResult(List.of("a@x.com"), true, null))));
@@ -59,7 +60,8 @@ void returnsOkAndApiResponderOnSuccess() throws Exception {
post("/api/email/send")
.contentType(MediaType.APPLICATION_JSON)
.content(
- "{\"subject\":\"S\",\"body\":\"B\",\"messages\":[{\"recipients\":[{\"email\":\"a@x.com\"}]}]}"))
+ "{\"templateId\":\"" + templateId
+ + "\",\"subject\":\"S\",\"body\":\"B\",\"messages\":[{\"recipients\":[{\"email\":\"a@x.com\"}]}]}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.payload.sent").value(1));
@@ -67,11 +69,13 @@ void returnsOkAndApiResponderOnSuccess() throws Exception {
@Test
void returnsBadRequestOnInvalidEmail() throws Exception {
+ final UUID templateId = UUID.randomUUID();
mockMvc.perform(
post("/api/email/send")
.contentType(MediaType.APPLICATION_JSON)
.content(
- "{\"subject\":\"S\",\"body\":\"B\",\"messages\":[{\"recipients\":[{\"email\":\"not-an-email\"}]}]}"))
+ "{\"templateId\":\"" + templateId
+ + "\",\"subject\":\"S\",\"body\":\"B\",\"messages\":[{\"recipients\":[{\"email\":\"not-an-email\"}]}]}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false));
}
diff --git a/src/test/java/org/patinanetwork/patchats/email/EmailServiceTest.java b/src/test/java/org/patinanetwork/patchats/email/EmailServiceTest.java
index 772544df..42040f35 100644
--- a/src/test/java/org/patinanetwork/patchats/email/EmailServiceTest.java
+++ b/src/test/java/org/patinanetwork/patchats/email/EmailServiceTest.java
@@ -7,23 +7,48 @@
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
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.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.SendEmailRequest;
import org.patinanetwork.patchats.email.dto.SendEmailResponse;
import org.springframework.mail.MailSendException;
class EmailServiceTest {
+ private final EmailTemplateRepo templateRepo = mock(EmailTemplateRepo.class);
+ private final EmailRequestRepo requestRepo = mock(EmailRequestRepo.class);
+ private final EmailRepo emailRepo = mock(EmailRepo.class);
private final EmailSender sender = mock(EmailSender.class);
- private final EmailService service = new EmailService(new TemplateRenderer(), sender);
+ private final EmailService service =
+ new EmailService(templateRepo, requestRepo, emailRepo, new TemplateRenderer(), sender);
+
+ EmailServiceTest() {
+ // Mock templateRepo to return a template for any ID
+ when(templateRepo.findById(any(UUID.class))).thenAnswer(invocation -> {
+ final UUID id = invocation.getArgument(0);
+ return Optional.of(EmailTemplate.builder()
+ .id(id)
+ .name("test")
+ .subject("Test Subject")
+ .body("Test Body")
+ .build());
+ });
+ }
@Test
void sendsPairAsOneEmailWithNamespacedVariables() {
final SendEmailRequest request = new SendEmailRequest(
+ UUID.randomUUID(),
"Hi ${per1.firstName} & ${per2.firstName}",
"Paired for ${month}. LinkedIn: ${per2.linkedIn:N/A}",
null,
@@ -50,6 +75,7 @@ void sendsPairAsOneEmailWithNamespacedVariables() {
@Test
void missingRequiredVariableFailsOnlyThatMessage() {
final SendEmailRequest request = new SendEmailRequest(
+ UUID.randomUUID(),
"Hi ${per1.firstName}",
"Body",
null,
@@ -72,6 +98,7 @@ void missingRequiredVariableFailsOnlyThatMessage() {
void smtpFailureIsReportedPerMessage() {
doThrow(new MailSendException("smtp down")).when(sender).send(any());
final SendEmailRequest request = new SendEmailRequest(
+ UUID.randomUUID(),
"S",
"B",
null,