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,