diff --git a/js/src/app/router/router.tsx b/js/src/app/router/router.tsx
index 9ea6b47..8de43e5 100644
--- a/js/src/app/router/router.tsx
+++ b/js/src/app/router/router.tsx
@@ -5,7 +5,7 @@ import { RequireAdmin } from "@/app/router/guards/RequireAdmin";
import { RequireAuth } from "@/app/router/guards/RequireAuth";
import AdminPage from "@/features/admin/Admin.page";
import AdminLoginPage from "@/features/admin/AdminLogin.page";
-import EmailAdminPage from "@/features/emails/EmailAdminPage";
+import AdminEmailComposerPage from "@/features/emails/AdminEmailComposer.page";
import HomePage from "@/features/home/Home.page";
import SamplePage from "@/features/sample/Sample.page";
import SampleAdminPage from "@/features/sample/SampleAdmin.page";
@@ -20,11 +20,6 @@ export const router = createBrowserRouter([
element: ,
children: [{ index: true, element: }],
},
- {
- element: ,
- children: [{ path: "email/admin", element: }],
- },
-
// Authenticated: guard -> layout -> page. Admin nests a second guard + layout.
{
element: ,
@@ -40,6 +35,7 @@ export const router = createBrowserRouter([
element: ,
children: [
{ path: "admin", element: },
+ { path: "admin/emails", element: },
{ path: "sample/admin", element: },
],
},
diff --git a/js/src/features/emails/AdminEmailComposer.page.test.tsx b/js/src/features/emails/AdminEmailComposer.page.test.tsx
new file mode 100644
index 0000000..a855f24
--- /dev/null
+++ b/js/src/features/emails/AdminEmailComposer.page.test.tsx
@@ -0,0 +1,105 @@
+import AdminEmailComposerPage from "@/features/emails/AdminEmailComposer.page";
+import {
+ renderWithProviders,
+ screen,
+ waitFor,
+ within,
+} from "@/lib/test/render";
+import { server } from "@/lib/test/server";
+import userEvent from "@testing-library/user-event";
+import { http, HttpResponse } from "msw";
+import { expect, test } from "vitest";
+
+async function selectTemplate(
+ user: ReturnType,
+ name: string,
+) {
+ await user.click(screen.getByPlaceholderText("Select email template"));
+ await user.click(await screen.findByText(name));
+ await screen.findByText("4 recipients");
+}
+
+test("requires a template before opening confirmation", async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders();
+
+ await user.click(screen.getByRole("button", { name: /review send/i }));
+
+ expect(
+ await screen.findByText("Email template is required"),
+ ).toBeInTheDocument();
+});
+
+test("renders a paired template and sends its backend batch payload", async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders();
+
+ await selectTemplate(user, "Pair");
+
+ expect(screen.getAllByText("Matched users")).toHaveLength(2);
+ expect(screen.getByText("4 recipients")).toBeInTheDocument();
+ expect(screen.getByLabelText("Plain text body")).toHaveAttribute("readonly");
+ expect(screen.getAllByText(/Alice and Bob/)).toHaveLength(2);
+
+ await user.click(screen.getByRole("button", { name: /review send/i }));
+
+ const dialog = await screen.findByRole("dialog", { name: /confirm send/i });
+
+ expect(within(dialog).getByText("Matched users")).toBeInTheDocument();
+ expect(within(dialog).getByText("4")).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: /send email/i }));
+
+ expect(await screen.findByText("Email send complete")).toBeInTheDocument();
+ expect(screen.getByText("Sent 2, failed 0")).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.queryByText("Confirm send")).not.toBeInTheDocument();
+ });
+});
+
+test("shows failed recipient addresses returned by the backend", async () => {
+ const user = userEvent.setup();
+
+ server.use(
+ http.post("/api/email/send", () =>
+ HttpResponse.json({
+ message: "Sent 1 of 2 emails",
+ payload: {
+ failed: 1,
+ results: [
+ {
+ error: null,
+ recipients: ["alice@example.com", "bob@example.com"],
+ sent: true,
+ },
+ {
+ error: "Mailbox unavailable",
+ recipients: ["carol@example.com", "david@example.com"],
+ sent: false,
+ },
+ ],
+ sent: 1,
+ },
+ success: true,
+ }),
+ ),
+ );
+
+ renderWithProviders();
+
+ await selectTemplate(user, "Pair");
+ await user.click(screen.getByRole("button", { name: /review send/i }));
+ await user.click(await screen.findByRole("button", { name: /send email/i }));
+
+ expect(
+ await screen.findByText("Email send completed with failures"),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Sent 1, failed 1")).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ /carol@example.com, david@example.com: Mailbox unavailable/,
+ ),
+ ).toBeInTheDocument();
+});
diff --git a/js/src/features/emails/AdminEmailComposer.page.tsx b/js/src/features/emails/AdminEmailComposer.page.tsx
new file mode 100644
index 0000000..4a41b7e
--- /dev/null
+++ b/js/src/features/emails/AdminEmailComposer.page.tsx
@@ -0,0 +1,375 @@
+import {
+ buildEmailRequest,
+ countRecipients,
+ getMessageVariables,
+ renderTemplate,
+ SendEmailResponse,
+} from "@/features/emails/api/buildEmailRequest";
+import {
+ getTemplate,
+ isSelectableEmailTemplateName,
+ selectableEmailTemplateNames,
+} from "@/features/emails/api/EmailTemplate";
+import {
+ emailComposerSchema,
+ EmailComposerValues,
+} from "@/features/emails/api/schemas";
+import { useSendAdminEmail } from "@/features/emails/api/useSendAdminEmail";
+import { useTemplateAudience } from "@/features/emails/api/useTemplateAudience";
+import {
+ Alert,
+ Badge,
+ Button,
+ Card,
+ Divider,
+ Group,
+ Loader,
+ Modal,
+ Select,
+ SimpleGrid,
+ Stack,
+ Text,
+ Textarea,
+ TextInput,
+ Title,
+} from "@mantine/core";
+import { useForm } from "@mantine/form";
+import { notifications } from "@mantine/notifications";
+import {
+ IconCheck,
+ IconEyeCheck,
+ IconFileText,
+ IconMail,
+ IconSend,
+ IconUserCheck,
+ IconUsers,
+} from "@tabler/icons-react";
+import { zodResolver } from "mantine-form-zod-resolver";
+import { useMemo, useState } from "react";
+
+const initialValues: EmailComposerValues = {
+ templateName: "",
+};
+
+function formatCount(value: number) {
+ return value.toLocaleString();
+}
+
+function getRecipientLabel(audience: "all-users" | "matched-pairs") {
+ return audience === "all-users" ? "All users" : "Matched users";
+}
+
+function getRecipientIcon(audience: "all-users" | "matched-pairs") {
+ return audience === "all-users" ?
+
+ : ;
+}
+
+function getFailedResults(sendResult: SendEmailResponse | null) {
+ return sendResult?.results.filter((result) => !result.sent) ?? [];
+}
+
+export default function AdminEmailComposerPage() {
+ const [isConfirmationOpen, setIsConfirmationOpen] = useState(false);
+ const [sendResult, setSendResult] = useState(null);
+ const form = useForm({
+ initialValues,
+ validate: zodResolver(emailComposerSchema),
+ });
+ const selectedTemplateName =
+ isSelectableEmailTemplateName(form.values.templateName) ?
+ form.values.templateName
+ : undefined;
+ const selectedTemplate =
+ selectedTemplateName ? getTemplate(selectedTemplateName) : undefined;
+ const {
+ data: templateAudience,
+ error: templateAudienceError,
+ isError: isTemplateAudienceError,
+ isPending: isTemplateAudienceQueryPending,
+ } = useTemplateAudience(selectedTemplateName);
+ const isTemplateAudiencePending =
+ selectedTemplateName !== undefined && isTemplateAudienceQueryPending;
+ const sendEmail = useSendAdminEmail();
+
+ const emailRequest = useMemo(
+ () =>
+ selectedTemplate && templateAudience ?
+ buildEmailRequest(selectedTemplate, templateAudience)
+ : undefined,
+ [selectedTemplate, templateAudience],
+ );
+ const previewMessage = emailRequest?.messages[0];
+ const previewVariables =
+ previewMessage ? getMessageVariables(previewMessage) : undefined;
+ const previewSubject =
+ selectedTemplate && previewVariables ?
+ renderTemplate(selectedTemplate.subject, previewVariables)
+ : "Subject";
+ const previewBody =
+ selectedTemplate && previewVariables ?
+ renderTemplate(selectedTemplate.body, previewVariables)
+ : "Plain text body";
+ const recipientCount =
+ emailRequest ? countRecipients(emailRequest.messages) : 0;
+ const recipientLabel =
+ templateAudience ?
+ getRecipientLabel(templateAudience.audience)
+ : "Recipients";
+ const failedResults = getFailedResults(sendResult);
+
+ const openConfirmation = () => {
+ const validation = form.validate();
+
+ if (!validation.hasErrors && emailRequest) {
+ setIsConfirmationOpen(true);
+ }
+ };
+
+ const handleTemplateChange = (templateName: string | null) => {
+ form.setFieldValue("templateName", templateName ?? "");
+ setSendResult(null);
+ };
+
+ const confirmSend = async () => {
+ if (!emailRequest) {
+ return;
+ }
+
+ const result = await sendEmail.mutateAsync(emailRequest);
+
+ setIsConfirmationOpen(false);
+ setSendResult(result);
+ notifications.show({
+ color: result.failed > 0 ? "yellow" : "green",
+ icon: ,
+ message: `Sent ${result.sent}, failed ${result.failed}`,
+ title: "Email send complete",
+ });
+ };
+
+ return (
+ <>
+
+
+
+ Emails
+ Composer
+
+ {sendResult ?
+ 0 ? "yellow" : "green"}
+ leftSection={}
+ >
+ Sent
+
+ : null}
+
+ {sendResult ?
+ 0 ? "yellow" : "green"}
+ icon={}
+ title={
+ sendResult.failed > 0 ?
+ "Email send completed with failures"
+ : "Email send complete"
+ }
+ >
+
+
+ Sent {sendResult.sent}, failed {sendResult.failed}
+
+ {failedResults.length > 0 ?
+
+ Failed recipients
+ {failedResults.map((result) => (
+
+ {result.recipients.join(", ")}
+ {result.error ? `: ${result.error}` : ""}
+
+ ))}
+
+ : null}
+
+
+ : null}
+ {sendEmail.isError ?
+
+ {sendEmail.error.message}
+
+ : null}
+
+
+
+
+
+
+ Email template
+
+
+
+ {isTemplateAudiencePending ?
+
+
+
+ Loading recipients
+
+
+ : null}
+ {isTemplateAudienceError ?
+
+ {templateAudienceError.message}
+
+ : null}
+ {templateAudience && emailRequest ?
+
+
+ {getRecipientIcon(templateAudience.audience)}
+
+ {recipientLabel}
+
+ {emailRequest.messages.length} messages
+
+
+
+
+ {formatCount(recipientCount)}
+
+
+ : null}
+
+
+
+
+ }
+ onClick={openConfirmation}
+ >
+ Review send
+
+
+
+
+
+
+
+ Preview
+
+
+
+
+ To
+
+ {recipientLabel}
+ {emailRequest ?
+
+ {formatCount(recipientCount)} recipients
+
+ : null}
+
+
+
+
+ Reply-to
+
+
+ {selectedTemplate?.replyTo ?? "Default sender"}
+
+
+
+
+ Subject
+
+ {previewSubject}
+
+
+
+ {previewBody}
+
+
+
+
+
+ setIsConfirmationOpen(false)}
+ opened={isConfirmationOpen}
+ title="Confirm send"
+ >
+
+
+ Recipient type
+ {recipientLabel}
+
+
+ Estimated count
+ {formatCount(recipientCount)}
+
+
+ Subject
+
+ {previewSubject}
+
+
+
+
+
+ }
+ loading={sendEmail.isPending}
+ onClick={() => void confirmSend()}
+ >
+ Send email
+
+
+
+
+ >
+ );
+}
diff --git a/js/src/features/emails/CsvUpload.tsx b/js/src/features/emails/CsvUpload.tsx
deleted file mode 100644
index ec795cb..0000000
--- a/js/src/features/emails/CsvUpload.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-import { readFiles } from "@/features/emails/api/parseCSV";
-import { FileInput, Button, Stack, NativeSelect } from "@mantine/core";
-import { notifications } from "@mantine/notifications";
-import { useState } from "react";
-
-export function CsvSubmitButton({ onSubmit }: { onSubmit: () => void }) {
- return ;
-}
-
-export function UserCsvUpload({
- value,
- onChange,
-}: {
- value: File | null;
- onChange: (file: File | null) => void;
-}) {
- return (
-
- );
-}
-
-export function PairingCsvUpload({
- value,
- onChange,
-}: {
- value: File | null;
- onChange: (file: File | null) => void;
-}) {
- return (
-
- );
-}
-export function TemplateSelect({
- value,
- onChange,
-}: {
- value: string;
- onChange: React.Dispatch>;
-}) {
- return (
- onChange(event.currentTarget.value)}
- label="Email Templates"
- description="Select an email template"
- data={["Pair", "Reminder", "Restart"]}
- />
- );
-}
-
-export function CsvUpload() {
- const [userFile, setUserFile] = useState(null);
- const [pairingFile, setPairingFile] = useState(null);
- const [template, setTemplate] = useState("");
-
- const handleSubmit = async () => {
- if (!userFile && !pairingFile) {
- notifications.show({
- color: "red",
- title: "Missing files",
- message: "Please upload both a User CSV and a Pairing CSV.",
- });
- return;
- }
- if (!userFile) {
- notifications.show({
- color: "red",
- title: "Missing file",
- message: "Please upload a User CSV.",
- });
- return;
- }
- if (!template) {
- notifications.show({
- color: "red",
- title: "Missing template",
- message: "Please select an email template.",
- });
- return;
- }
- await Promise.all([readFiles(userFile, pairingFile, template)]);
- };
-
- return (
-
-
-
-
- void handleSubmit()} />
-
- );
-}
diff --git a/js/src/features/emails/EmailAdminPage.tsx b/js/src/features/emails/EmailAdminPage.tsx
deleted file mode 100644
index 2d2955f..0000000
--- a/js/src/features/emails/EmailAdminPage.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-import { CsvUpload } from "@/features/emails/CsvUpload";
-
-export default function EmailAdminPage() {
- return (
-
-
-
- );
-}
diff --git a/js/src/features/emails/api/EmailTemplate.ts b/js/src/features/emails/api/EmailTemplate.ts
index 4066c96..ea3fd31 100644
--- a/js/src/features/emails/api/EmailTemplate.ts
+++ b/js/src/features/emails/api/EmailTemplate.ts
@@ -32,6 +32,17 @@ export interface EmailTemplate {
replyTo: string | null;
}
+export const selectableEmailTemplateNames = [
+ "Pair",
+ "Reminder",
+ "Restart",
+] as const;
+
+export type SelectableEmailTemplateName =
+ (typeof selectableEmailTemplateNames)[number];
+
+export type TemplateAudience = "all-users" | "matched-pairs";
+
/**
* Builds an {@link EmailTemplate} from preconfigured strings.
* @param name Human-readable name shown in the admin UI.
@@ -130,10 +141,31 @@ export const emailTemplateMap: Record = {
Restart: RESTART_TEMPLATE,
};
-export function getTemplate(name: string): EmailTemplate {
+const templateAudiences: Record =
+ {
+ Pair: "matched-pairs",
+ Reminder: "matched-pairs",
+ Restart: "all-users",
+ };
+
+export function getTemplate(name: string): EmailTemplate | undefined {
return emailTemplateMap[name];
}
export function getAllTemplateNames(): string[] {
return Object.keys(emailTemplateMap);
}
+
+export function isSelectableEmailTemplateName(
+ value: string,
+): value is SelectableEmailTemplateName {
+ return selectableEmailTemplateNames.includes(
+ value as SelectableEmailTemplateName,
+ );
+}
+
+export function getTemplateAudience(
+ templateName: SelectableEmailTemplateName,
+): TemplateAudience {
+ return templateAudiences[templateName];
+}
diff --git a/js/src/features/emails/api/buildEmailRequest.test.ts b/js/src/features/emails/api/buildEmailRequest.test.ts
new file mode 100644
index 0000000..4268a52
--- /dev/null
+++ b/js/src/features/emails/api/buildEmailRequest.test.ts
@@ -0,0 +1,48 @@
+import {
+ buildEmailRequest,
+ renderTemplate,
+ TemplateAudienceData,
+} from "@/features/emails/api/buildEmailRequest";
+import { PAIR_TEMPLATE } from "@/features/emails/api/EmailTemplate";
+import { expect, test } from "vitest";
+
+const audience: TemplateAudienceData = {
+ audience: "matched-pairs",
+ pairs: [{ firstEmail: "alice@example.com", secondEmail: "bob@example.com" }],
+ users: [
+ { email: "alice@example.com", intro: "Alice intro", name: "Alice" },
+ { email: "bob@example.com", intro: "Bob intro", name: "Bob" },
+ ],
+};
+
+test("builds one two-recipient message for each matched pair", () => {
+ const request = buildEmailRequest(PAIR_TEMPLATE, audience);
+
+ expect(request.messages).toHaveLength(1);
+ expect(request.messages[0].recipients).toEqual([
+ {
+ email: "alice@example.com",
+ variableToValue: {
+ email: "alice@example.com",
+ intro: "Alice intro",
+ name: "Alice",
+ },
+ },
+ {
+ email: "bob@example.com",
+ variableToValue: {
+ email: "bob@example.com",
+ intro: "Bob intro",
+ name: "Bob",
+ },
+ },
+ ]);
+});
+
+test("renders nested placeholder defaults without leaving unmatched braces", () => {
+ expect(
+ renderTemplate("Hi ${per1.intro: {Intro missing}}!", {
+ "per1.intro": "Alice intro",
+ }),
+ ).toBe("Hi Alice intro!");
+});
diff --git a/js/src/features/emails/api/buildEmailRequest.ts b/js/src/features/emails/api/buildEmailRequest.ts
new file mode 100644
index 0000000..9561000
--- /dev/null
+++ b/js/src/features/emails/api/buildEmailRequest.ts
@@ -0,0 +1,229 @@
+import {
+ EmailTemplate,
+ TemplateAudience,
+} from "@/features/emails/api/EmailTemplate";
+
+export interface EmailSourceUser {
+ anything?: string;
+ email: string;
+ industry?: string;
+ intro?: string;
+ linkedIn?: string;
+ name: string;
+ preferences?: string;
+ topics?: string;
+}
+
+export interface EmailSourcePair {
+ firstEmail: string;
+ secondEmail: string;
+}
+
+export interface TemplateAudienceData {
+ audience: TemplateAudience;
+ pairs: EmailSourcePair[];
+ users: EmailSourceUser[];
+}
+
+export interface EmailRecipient {
+ email: string;
+ variableToValue: Record;
+}
+
+export interface EmailMessage {
+ recipients: EmailRecipient[];
+ variables?: Record;
+}
+
+export interface SendEmailRequest {
+ body: string;
+ messages: EmailMessage[];
+ replyTo: string | null;
+ subject: string;
+}
+
+export interface SendEmailResponse {
+ failed: number;
+ results: Array<{
+ error: string | null;
+ recipients: string[];
+ sent: boolean;
+ }>;
+ sent: number;
+}
+
+function withoutEmptyValues(values: Record) {
+ return Object.fromEntries(
+ Object.entries(values).filter(([, value]) => value?.trim()),
+ ) as Record;
+}
+
+function toRecipient(user: EmailSourceUser): EmailRecipient {
+ return {
+ email: user.email,
+ variableToValue: withoutEmptyValues({
+ anything: user.anything,
+ email: user.email,
+ industry: user.industry,
+ intro: user.intro,
+ linkedin: user.linkedIn,
+ name: user.name,
+ preferences: user.preferences,
+ topics: user.topics,
+ }),
+ };
+}
+
+function createPairMessages(
+ usersByEmail: Map,
+ pairs: EmailSourcePair[],
+): EmailMessage[] {
+ return pairs.map((pair) => {
+ const firstUser = usersByEmail.get(pair.firstEmail);
+ const secondUser = usersByEmail.get(pair.secondEmail);
+
+ if (!firstUser || !secondUser) {
+ throw new Error(
+ `Pairing references unknown email: ${pair.firstEmail} or ${pair.secondEmail}`,
+ );
+ }
+
+ return { recipients: [toRecipient(firstUser), toRecipient(secondUser)] };
+ });
+}
+
+export function buildEmailMessages(audienceData: TemplateAudienceData) {
+ if (audienceData.audience === "all-users") {
+ return audienceData.users.map((user) => ({
+ recipients: [toRecipient(user)],
+ }));
+ }
+
+ return createPairMessages(
+ new Map(audienceData.users.map((user) => [user.email, user])),
+ audienceData.pairs,
+ );
+}
+
+export function buildEmailRequest(
+ template: EmailTemplate,
+ audienceData: TemplateAudienceData,
+): SendEmailRequest {
+ return {
+ body: template.body,
+ messages: buildEmailMessages(audienceData),
+ replyTo: template.replyTo,
+ subject: template.subject,
+ };
+}
+
+export function countRecipients(messages: EmailMessage[]) {
+ return messages.reduce(
+ (recipientCount, message) => recipientCount + message.recipients.length,
+ 0,
+ );
+}
+
+export function getMessageVariables(message: EmailMessage) {
+ const variables = { ...message.variables };
+
+ message.recipients.forEach((recipient, index) => {
+ const prefix = `per${index + 1}.`;
+
+ Object.entries(recipient.variableToValue).forEach(([key, value]) => {
+ variables[`${prefix}${key}`] = value;
+ });
+ });
+
+ return variables;
+}
+
+function findPlaceholderEnd(value: string, startIndex: number) {
+ let depth = 1;
+
+ for (let index = startIndex + 2; index < value.length; index += 1) {
+ if (value[index] === "{") {
+ depth += 1;
+ }
+
+ if (value[index] === "}") {
+ depth -= 1;
+
+ if (depth === 0) {
+ return index;
+ }
+ }
+ }
+
+ return -1;
+}
+
+function findDefaultSeparator(value: string) {
+ let depth = 0;
+
+ for (let index = 0; index < value.length; index += 1) {
+ if (value[index] === "{") {
+ depth += 1;
+ continue;
+ }
+
+ if (value[index] === "}") {
+ depth -= 1;
+ continue;
+ }
+
+ if (value[index] === ":" && depth === 0) {
+ return index;
+ }
+ }
+
+ return -1;
+}
+
+export function renderTemplate(
+ template: string,
+ variables: Record,
+) {
+ let rendered = "";
+ let cursor = 0;
+
+ while (cursor < template.length) {
+ const placeholderStart = template.indexOf("${", cursor);
+
+ if (placeholderStart === -1) {
+ return rendered + template.slice(cursor);
+ }
+
+ const placeholderEnd = findPlaceholderEnd(template, placeholderStart);
+
+ if (placeholderEnd === -1) {
+ return rendered + template.slice(cursor);
+ }
+
+ rendered += template.slice(cursor, placeholderStart);
+
+ const placeholder = template.slice(placeholderStart, placeholderEnd + 1);
+ const placeholderContent = template.slice(
+ placeholderStart + 2,
+ placeholderEnd,
+ );
+ const separatorIndex = findDefaultSeparator(placeholderContent);
+ const key =
+ separatorIndex === -1 ? placeholderContent : (
+ placeholderContent.slice(0, separatorIndex)
+ );
+ const defaultValue =
+ separatorIndex === -1 ? undefined : (
+ placeholderContent.slice(separatorIndex + 1)
+ );
+
+ rendered +=
+ variables[key] ??
+ (defaultValue === undefined ? placeholder : (
+ renderTemplate(defaultValue, variables)
+ ));
+ cursor = placeholderEnd + 1;
+ }
+
+ return rendered;
+}
diff --git a/js/src/features/emails/api/emails.mock.ts b/js/src/features/emails/api/emails.mock.ts
new file mode 100644
index 0000000..5a79e7a
--- /dev/null
+++ b/js/src/features/emails/api/emails.mock.ts
@@ -0,0 +1,23 @@
+import { SendEmailRequest } from "@/features/emails/api/buildEmailRequest";
+import { http, HttpResponse } from "msw";
+
+export const emailHandlers = [
+ http.post("/api/email/send", async ({ request }) => {
+ const requestBody = (await request.json()) as SendEmailRequest;
+ const results = requestBody.messages.map((message) => ({
+ error: null,
+ recipients: message.recipients.map((recipient) => recipient.email),
+ sent: true,
+ }));
+
+ return HttpResponse.json({
+ message: `Sent ${results.length} of ${results.length} emails`,
+ payload: {
+ failed: 0,
+ results,
+ sent: results.length,
+ },
+ success: true,
+ });
+ }),
+];
diff --git a/js/src/features/emails/api/mockTemplateAudience.ts b/js/src/features/emails/api/mockTemplateAudience.ts
new file mode 100644
index 0000000..f08408e
--- /dev/null
+++ b/js/src/features/emails/api/mockTemplateAudience.ts
@@ -0,0 +1,71 @@
+import { TemplateAudienceData } from "@/features/emails/api/buildEmailRequest";
+import {
+ getTemplateAudience,
+ SelectableEmailTemplateName,
+} from "@/features/emails/api/EmailTemplate";
+
+const mockUsers = [
+ {
+ anything: "Interested in local civic-tech projects.",
+ email: "alice@example.com",
+ industry: "Software engineering",
+ intro: "I build community-focused software.",
+ linkedIn: "https://linkedin.com/in/alice-example",
+ name: "Alice",
+ preferences: "Industry peers and mentors",
+ topics: "Open source, mentoring, and community building",
+ },
+ {
+ anything: "Enjoys early-morning coffee chats.",
+ email: "bob@example.com",
+ industry: "Product management",
+ intro: "I lead product teams for public-interest tools.",
+ linkedIn: "https://linkedin.com/in/bob-example",
+ name: "Bob",
+ preferences: "Cross-functional collaborators",
+ topics: "Product strategy and career growth",
+ },
+ {
+ anything: "Happy to meet virtually.",
+ email: "carol@example.com",
+ industry: "Design",
+ intro: "I design accessible digital experiences.",
+ linkedIn: "https://linkedin.com/in/carol-example",
+ name: "Carol",
+ preferences: "Design peers and founders",
+ topics: "Accessibility and research",
+ },
+ {
+ anything: "Prefers weekday afternoons.",
+ email: "david@example.com",
+ industry: "Data science",
+ intro: "I work on data products for nonprofits.",
+ linkedIn: "https://linkedin.com/in/david-example",
+ name: "David",
+ preferences: "Mission-driven technologists",
+ topics: "Data ethics and machine learning",
+ },
+];
+
+const mockPairs = [
+ { firstEmail: "alice@example.com", secondEmail: "bob@example.com" },
+ { firstEmail: "carol@example.com", secondEmail: "david@example.com" },
+];
+
+const wait = (durationMs: number) =>
+ new Promise((resolve) => {
+ window.setTimeout(resolve, durationMs);
+ });
+
+/** Replaced with a backend source-record query once that endpoint exists. */
+export async function getMockTemplateAudience(
+ templateName: SelectableEmailTemplateName,
+): Promise {
+ await wait(150);
+
+ return {
+ audience: getTemplateAudience(templateName),
+ pairs: mockPairs,
+ users: mockUsers,
+ };
+}
diff --git a/js/src/features/emails/api/parseCSV.ts b/js/src/features/emails/api/parseCSV.ts
deleted file mode 100644
index d8d34a0..0000000
--- a/js/src/features/emails/api/parseCSV.ts
+++ /dev/null
@@ -1,172 +0,0 @@
-import { emailTemplateMap } from "@/features/emails/api/EmailTemplate";
-import { parse, ParseResult } from "papaparse";
-
-interface User {
- Name: string;
- Email: string;
- Intro: string;
- LinkedIn: string;
- Industry: string;
- Preferences: string;
- Topics: string;
- Anything: string;
-}
-interface Pair {
- fullNameA: string;
- emailA: string;
- fullNameB: string;
- emailB: string;
-}
-
-export const readFiles = async (
- userFile: File,
- pairingFile: File | null,
- template: string,
-): Promise => {
- try {
- await Promise.resolve(combineData(userFile, pairingFile, template));
-
- return "success";
- } catch (error) {
- console.error("Error reading files:", error);
- return "Error reading files: " + error;
- }
-};
-
-async function combineData(
- userFile: File,
- pairFile: File | null,
- templateKey: string,
-): Promise {
- const userMap = await parseUserFile(userFile);
-
- // Drop empty/whitespace-only values so the key is absent from variableToValue. The backend
- // resolves missing keys (not empty strings) to a template default via ${x:default}
- const withoutEmpty = (vars: Record): Record =>
- Object.fromEntries(
- Object.entries(vars).filter(([, value]) => value?.trim()),
- );
-
- // Turn a full User record into a recipient oject with their variables.
- const toRecipient = (u: User) => ({
- email: u.Email,
- variableToValue: withoutEmpty({
- name: u.Name,
- email: u.Email,
- intro: u.Intro,
- linkedin: u.LinkedIn,
- industry: u.Industry,
- preferences: u.Preferences,
- topics: u.Topics,
- anything: u.Anything,
- }),
- });
-
- let messages;
- if (pairFile) {
- // One message per pair, addressed to two recipients. The pairing file only gives us names + emails,
- // so we look each person up in userMap to add the user with their full set of variables.
- const pairList = await parsePairingFile(pairFile);
- messages = pairList.map((p) => {
- const userA = userMap.get(p.emailA);
- const userB = userMap.get(p.emailB);
- if (!userA || !userB) {
- throw new Error(
- `Pairing references unknown email: ${p.emailA} or ${p.emailB}`,
- );
- }
- //Combines two users into a recipient list within a message object
- return { recipients: [toRecipient(userA), toRecipient(userB)] };
- });
- } else {
- // No pairing file: one message per user.
- messages = Array.from(userMap.values()).map((u) => ({
- recipients: [toRecipient(u)],
- }));
- }
-
- const template = emailTemplateMap[templateKey];
-
- const sendRequest = {
- subject: template.subject,
- body: template.body,
- replyTo: template.replyTo,
- messages,
- };
-
- await sendToEmailApi(sendRequest);
-}
-
-export async function parseUserFile(
- userFile: File,
-): Promise