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
22 changes: 12 additions & 10 deletions js/src/features/emails/EmailAdminPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,17 @@ export default function EmailAdminPage() {
return;
};
return (
<Tabs value={selectedTab} onChange={handleTabChange}>
<Tabs.List>
<Tabs.Tab value="send">Send Emails</Tabs.Tab>
<Tabs.Tab value="progress">Live Progress</Tabs.Tab>
<Tabs.Tab value="history">History</Tabs.Tab>
</Tabs.List>
<Box pt="lg">
<Outlet />
</Box>
</Tabs>
<Box px="md" pt="sm">
<Tabs value={selectedTab} onChange={handleTabChange}>
<Tabs.List>
<Tabs.Tab value="send">Send Emails</Tabs.Tab>
<Tabs.Tab value="progress">Live Progress</Tabs.Tab>
<Tabs.Tab value="history">History</Tabs.Tab>
</Tabs.List>
<Box pt="lg">
<Outlet />
</Box>
</Tabs>
</Box>
);
}
5 changes: 4 additions & 1 deletion js/src/features/emails/EmailSendPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -37,14 +38,16 @@ export function EmailSendPage() {
/>
{/* CSV uploader */}
<CsvUploader templateId={selectedTemplateId} setRequest={setRequest} />
{/* Send button */}
{/* Send Async button */}
<EmailSender
request={request}
selectedTemplateId={selectedTemplateId}
isSending={isSending}
setIsSending={setIsSending}
navigate={navigate}
/>
{/* Send Synchronous button */}
<SyncEmailSender request={request} />
</Stack>
<Box w="70%">
{/* Preview */}
Expand Down
30 changes: 19 additions & 11 deletions js/src/features/emails/_components/EmailSender.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
showEmailSuccess,
} from "@/features/emails/api/emailError";
import { type EnqueueEmailRequest } from "@/features/emails/dto/emailDto";
import { Button, Text } from "@mantine/core";
import { Button, Text, Tooltip, Flex } from "@mantine/core";
import { modals } from "@mantine/modals";
import { useMutation } from "@tanstack/react-query";
import { useEffect } from "react";
Expand Down Expand Up @@ -42,11 +42,12 @@ export function EmailSender({

const openModal = () =>
modals.openConfirmModal({
title: "Email Send Confirmation",
title: "ASYNCHRONOUS Email Send Confirmation",
children: (
<Text size="sm">
Please confirm that you want to send {request?.messages.length} email
{request?.messages.length === 1 ? "" : "s"}.
{request?.messages.length === 1 ? "" : "s"} asynchronously. Emails
will be queued up for send, then retried if an error occurs.
</Text>
),
labels: { confirm: "Confirm", cancel: "Cancel" },
Expand Down Expand Up @@ -95,13 +96,20 @@ export function EmailSender({
};

return (
<Button
onClick={openModal}
disabled={!request || !selectedTemplateId}
loading={isSending}
fullWidth
>
Send Emails
</Button>
<Flex>
<Tooltip
position="right"
label="Emails will be queued up for send, then retried if an error occurs."
>
<Button
onClick={openModal}
disabled={!request || !selectedTemplateId}
loading={isSending}
fullWidth
>
Send Asynchronous Emails (queues emails then sends)
</Button>
</Tooltip>
</Flex>
);
}
145 changes: 145 additions & 0 deletions js/src/features/emails/_components/SyncEmailSender.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
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, Tooltip } 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<EmailTemplate | null>(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]);
Comment thread
isabellalam12 marked this conversation as resolved.

useEffect(() => {
if (mutation.status === "pending") {
showEmailPending("pending", "Email sending in progress...");
}
}, [mutation.status]);

const openModal = () =>
modals.openConfirmModal({
title: "SYNCHRONOUS Email Send Confirmation",
children: (
<Text size="sm">
Please confirm that you want to send {request?.messages.length} email
{request?.messages.length === 1 ? "" : "s"} synchronously. Emails will
be sent immediately. Please do not leave the page until all email have
been marked as sent.
</Text>
),
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 (
<Flex>
<Tooltip
position="right"
label="Emails will be sent immediately. Please do not leave the page until all email have been marked as sent."
>
<Button
fullWidth
loading={mutation.isPending}
onClick={openModal}
disabled={!request || !template}
>
Send Synchronous Emails (immediately sends emails)
</Button>
</Tooltip>
</Flex>
);
}
1 change: 1 addition & 0 deletions js/src/features/emails/dto/emailDto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface MessagePreview {
}

export interface SendRequest {
templateId: string;
subject: string;
body: string;
replyTo: string | null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public ResponseEntity<ApiResponder<SendEmailResponse>> send(@Valid @RequestBody
@PostMapping("/send/async")
public ResponseEntity<ApiResponder<EnqueueEmailResponse>> 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));
}
Expand Down
70 changes: 64 additions & 6 deletions src/main/java/org/patinanetwork/patchats/email/EmailService.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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) {
Comment thread
isabellalam12 marked this conversation as resolved.
final EmailTemplate template = templateRepo
.findById(request.templateId())
.orElseThrow(() -> new EmailTemplateNotFoundException(request.templateId()));

final Optional<String> replyTo = Optional.ofNullable(request.replyTo()).filter(StringUtils::hasText);
final List<SendEmailResponse.MessageResult> 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<String> recipients = message.recipients().stream()
.map(SendEmailRequest.Recipient::email)
.toList();
try {
final Map<String, String> 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));
Expand All @@ -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<Email> 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<String, String> variables =
EmailService.mergeVariables(message.variables(), message.recipients());
variables.putIfAbsent("month", currentMonth);
final List<SendEmailRequest.Recipient> 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())
Comment thread
isabellalam12 marked this conversation as resolved.
.build());
}
emailRepo.insertAll(emails);
} catch (Exception ex) {
log.error("Failed to insert emails: {}", ex.getMessage());
}
return new SendEmailResponse(sent, failed, results);
}

Expand All @@ -66,8 +124,8 @@ public PreviewEmailResponse preview(final SendEmailRequest request) {
.toList();
try {
final Map<String, String> 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()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,7 @@
/** Which producer enqueued a sending session. */
public enum EmailSource {
MANUAL,
MATCHING
MATCHING,
SYNCHRONOUS,
Comment thread
isabellalam12 marked this conversation as resolved.
ASYNCHRONOUS,
}
Loading
Loading