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
4 changes: 2 additions & 2 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ frontend-test *args:
# cd email && pnpm i && pnpm email dev --dir emails {{args}}
#
## Generate HTML output of react-email and copy to backend static folder
#email-gen *args:
# cd email && bash email.sh {{args}}
email-gen *args:
cd email && bash email.sh {{args}}

# Run the dev servers (backend & frontend)
dev *args:
Expand Down
2 changes: 2 additions & 0 deletions js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"immer": "^10.1.3",
"mantine-form-zod-resolver": "^1.3.0",
"motion": "^12.34.2",
"papaparse": "^5.5.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^5.4.0",
Expand All @@ -53,6 +54,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/papaparse": "^5.5.2",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react-syntax-highlighter": "^15.5.13",
Expand Down
22 changes: 19 additions & 3 deletions js/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

80 changes: 80 additions & 0 deletions js/src/app/user/admin/emails/CsvUpload.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"use client";

import { Button, FileInput, Stack, Text, Title } from "@mantine/core";
import { useState } from "react";
import { parsePairingFile, parseUserFile } from "src/app/user/admin/emails/parseCSV";

export default function CsvUpload() {
const [userFile, setUserFile] = useState<File | null>(null);
const [pairFile, setPairFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);

async function handleSendUsers() {
if (!userFile) return;
setLoading(true);
setStatus(null);
setError(null);
try {
const users = await parseUserFile(userFile);
setStatus(`Sent user emails to ${users.size} recipient(s).`);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to send user emails.");
} finally {
setLoading(false);
}
}

async function handleSendPairings() {
if (!pairFile) return;
setLoading(true);
setStatus(null);
setError(null);
try {
const pairs = await parsePairingFile(pairFile);
setStatus(`Sent pairing emails for ${pairs.length} pairing(s).`);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to send pairing emails.");
} finally {
setLoading(false);
}
}

return (
<Stack gap="md" maw={480}>
<Title order={3}>Send Emails</Title>

<Stack gap="xs">
<FileInput
label="Users CSV"
placeholder="Choose users CSV"
accept=".csv,text/csv"
value={userFile}
onChange={setUserFile}
clearable
/>
<Button onClick={handleSendUsers} disabled={!userFile} loading={loading}>
Send user emails
</Button>
</Stack>

<Stack gap="xs">
<FileInput
label="Pairings CSV"
placeholder="Choose pairings CSV"
accept=".csv,text/csv"
value={pairFile}
onChange={setPairFile}
clearable
/>
<Button onClick={handleSendPairings} disabled={!pairFile} loading={loading}>
Send pairing emails
</Button>
</Stack>

{status && <Text c="green">{status}</Text>}
{error && <Text c="red">{error}</Text>}
</Stack>
);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.patinanetwork.patchats.api;


import io.micrometer.core.annotation.Timed;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package org.patinanetwork.patchats.api.emails;

import io.micrometer.core.annotation.Timed;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.patinanetwork.patchats.common.dto.ApiResponder;
import org.patinanetwork.patchats.common.email.EmailClient;
import org.patinanetwork.patchats.common.email.options.SendEmailOptions;
import org.patinanetwork.patchats.common.email.template.ReactEmailTemplater;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
* Receives CSV-parsed payloads from the admin frontend (see {@code js/src/app/user/admin/emails/parseCSV.ts}) and sends
* the corresponding emails by rendering a React Email template then dispatching it through the {@link EmailClient}.
*/
@RestController
@RequestMapping("/api/emails")
@Tag(name = "Email sending")
@Timed(value = "controller.execution")
public class EmailController {

// TODO - These are placeholders. The example template is a "verify email" template; user/pairing emails likely

Check warning on line 29 in src/main/java/org/patinanetwork/patchats/api/emails/EmailController.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this TODO comment.

See more on https://sonarcloud.io/project/issues?id=Patina-Network_patchats&issues=AZ6ogIrXOrhjeia7HIDF&open=AZ6ogIrXOrhjeia7HIDF&pullRequest=16
// need their own templates and a real verify URL / support address sourced from configuration.
private static final String SUPPORT_EMAIL = "patchats@patinanetwork.org";
private static final String VERIFY_URL = "https://patchats.org/verify";

private final ReactEmailTemplater templater;
private final EmailClient emailClient;

public EmailController(final ReactEmailTemplater templater, final EmailClient emailClient) {
this.templater = templater;
this.emailClient = emailClient;
}

@Operation(summary = "Send an email to each user parsed from the uploaded users CSV")
@PostMapping("/send-users")
public ResponseEntity<ApiResponder<SendResult>> sendUsers(@RequestBody final Map<String, UserDto> users) {
final SendResult result = new SendResult();
for (final UserDto user : users.values()) {
try {
final String html = templater.createExampleTemplate(user.name(), VERIFY_URL, SUPPORT_EMAIL);
send(user.email(), "Welcome to PatChats", html, result);
} catch (final Exception e) {
result.fail(user.email(), e);
}
}
return ResponseEntity.ok().body(ApiResponder.success("Processed user emails", result));
}

@Operation(summary = "Send an email to both members of each pairing parsed from the uploaded pairings CSV")
@PostMapping("/send-pairings")
public ResponseEntity<ApiResponder<SendResult>> sendPairings(@RequestBody final List<PairDto> pairs) {
final SendResult result = new SendResult();
for (final PairDto pair : pairs) {
sendPairingMember(pair.fullNameA(), pair.emailA(), result);
sendPairingMember(pair.fullNameB(), pair.emailB(), result);
}
return ResponseEntity.ok().body(ApiResponder.success("Processed pairing emails", result));
}

private void sendPairingMember(final String name, final String email, final SendResult result) {
try {
// TODO - Reusing the example template; swap for a dedicated pairing-notification template.

Check warning on line 70 in src/main/java/org/patinanetwork/patchats/api/emails/EmailController.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this TODO comment.

See more on https://sonarcloud.io/project/issues?id=Patina-Network_patchats&issues=AZ6ogIrXOrhjeia7HIDG&open=AZ6ogIrXOrhjeia7HIDG&pullRequest=16
final String html = templater.createExampleTemplate(name, VERIFY_URL, SUPPORT_EMAIL);
send(email, "Your PatChats pairing", html, result);
} catch (final Exception e) {
result.fail(email, e);
}
}

private void send(final String recipientEmail, final String subject, final String html, final SendResult result)
throws Exception {
emailClient.sendMessage(SendEmailOptions.builder()
.recipientEmail(recipientEmail)
.subject(subject)
.body(html)
.build());
result.succeed();
}

/** Summary of a batch send so the frontend can report how many emails went out. */
public static final class SendResult {
private int sent = 0;
private final List<String> errors = new ArrayList<>();

void succeed() {
sent++;
}

void fail(final String email, final Exception e) {
errors.add(email + ": " + e.getMessage());
}

public int getSent() {
return sent;
}

public int getFailed() {
return errors.size();
}

public List<String> getErrors() {
return errors;
}
}

/** Mirrors the {@code User} interface serialized by parseCSV.ts. */
public record UserDto(
String name,
String email,
String intro,
String linkedin,
String industry,
String preferences,
String topics,
String anything) {}

/** Mirrors the {@code Pair} interface serialized by parseCSV.ts. */
public record PairDto(String fullNameA, String emailA, String fullNameB, String emailB) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package org.patinanetwork.patchats.common.email;

import java.util.List;
import org.patinanetwork.patchats.common.email.error.EmailException;
import org.patinanetwork.patchats.common.email.options.SendEmailOptions;

/**
* The base email interface.
*
* <p>NOTE: Inherited classes may NOT have every method implemented. You should check the implementation of each Email
* type and ensure that it has the features you require.
*/
public abstract class EmailClient {

public abstract List<Message> getPastMessages() throws EmailException;

public abstract void sendMessage(SendEmailOptions sendEmailOptions) throws EmailException;

/** Validate that the connection and all the properties work, without actually doing any action. */
public abstract void testConnection() throws EmailException;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package org.patinanetwork.patchats.common.email;

import java.util.Date;

/**
* This class exists due to the fact that when the email connection is closed, all Messages are subsequently closed as
* well.
*/
public class Message {

private final String subject;
private final String message;

Check warning on line 12 in src/main/java/org/patinanetwork/patchats/common/email/Message.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename field "message"

See more on https://sonarcloud.io/project/issues?id=Patina-Network_patchats&issues=AZ6UCg5WCJV5C2-l66mg&open=AZ6UCg5WCJV5C2-l66mg&pullRequest=16
private final Date sentAt;

public Message(final String subject, final String message, final Date sentAt) {
this.subject = subject;
this.message = message;
this.sentAt = sentAt;
}

public String getSubject() {
return subject;
}

public String getMessage() {
return message;
}

public Date getSentAt() {
return sentAt;
}
}
Loading
Loading