-
Notifications
You must be signed in to change notification settings - Fork 0
Emails: Set up email backend structure #26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
isabellalam12
merged 5 commits into
main
from
06-09-emails_set_up_email_backend_structure
Jun 19, 2026
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
a274084
Emails: Set up email backend structure
isabellalam12 a55aae3
Update email-feature.md
isabellalam12 6279b00
Condense email-feature.md, rename variable, add JSON examples.
isabellalam12 d520b63
Change 'recipient' variables to 'rec'
isabellalam12 a8a058e
Change rec to per. Run prettier on JSON file
isabellalam12 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,3 @@ | ||
| { | ||
| "java.checkstyle.configuration": "./checkstyle.xml" | ||
| } | ||
| "java.checkstyle.configuration": "./checkstyle.xml" | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| # Email feature (backend) | ||
|
|
||
| How the backend `email` domain sends transactional email (e.g. monthly pairing notifications). | ||
|
|
||
| **What it does.** The frontend POSTs _who_ to email plus the _content_ — a subject + body | ||
| template and the variables to fill in. The backend merges the variables into the templates and | ||
| sends the result over SMTP. | ||
|
|
||
| **How it's organised.** v1 is **plain-text only**; HTML is planned for later. So the module uses | ||
| a **domain-first (package-by-feature)** layout — the same idea as the frontend (see | ||
| `js/docs/frontend-structure.md`): one flat `email` package that only splits into sub-packages when | ||
| it needs to, with the single external dependency (SMTP) hidden behind a _port_ (a Java interface | ||
| whose implementation can be swapped). | ||
|
|
||
| ## The shape | ||
|
|
||
| ``` | ||
| src/main/java/org/patinanetwork/patchats/email/ | ||
| EmailController.java POST /api/email/send → ResponseEntity<ApiResponder<SendEmailResponse>> | ||
| EmailService.java orchestration: per message build vars → render → send → collect results (+ @Slf4j) | ||
| EmailSender.java PORT: void send(OutgoingEmail email) | ||
| SmtpEmailSender.java @Profile("!dev") — JavaMailSender + SimpleMailMessage (plain text) | ||
| LoggingEmailSender.java @Profile("dev") — renders + logs instead of sending (no real SMTP) | ||
| TemplateRenderer.java logic-less ${} interpolation (Spring PropertyPlaceholderHelper) | ||
| OutgoingEmail.java internal record: List<String> to (1–2), subject, body, Optional<String> replyTo | ||
| EmailProperties.java @ConfigurationProperties("app.email") → from, fromName | ||
| dto/ | ||
| SendEmailRequest.java subject, body, replyTo?, List<Message> messages | ||
| Message(Map<String,String> variables?, List<Recipient> recipients) // 1–2 | ||
| Recipient(String email, Map<String,String> variableToValue) | ||
| SendEmailResponse.java int sent, int failed, List<MessageResult> | ||
| MessageResult(List<String> recipients, boolean sent, String error?) | ||
|
|
||
| common/web/ | ||
| ApiExceptionHandler.java @RestControllerAdvice: bean-validation errors → ApiResponder.failure (400) | ||
| ``` | ||
|
|
||
| The SMTP transport is the only piece that touches the outside world, so it sits behind `EmailSender`. That one seam buys three things: the dev profile swaps in a no-send logging implementation, integration tests inject a fake, and the future HTML switch is confined to the adapter. | ||
|
|
||
| ## API contract | ||
|
|
||
| `POST /api/email/send` — subject and body are templates; substitution runs **once per message**. | ||
|
|
||
| ### Request | ||
|
|
||
| ```jsonc | ||
| { | ||
| "subject": "...", // ${} template, required | ||
| "body": "...", // ${} template, required | ||
| "replyTo": "...", // optional; the "From" address is set in .env | ||
| "messages": [ | ||
| { | ||
| "variables": { ... }, // optional shared vars → referenced un-prefixed, e.g. ${month} | ||
| "recipients": [ // 1 or 2 recipients | ||
| { "email": "...", "variableToValue": { ... } }, // becomes ${per1.*} | ||
| { "email": "...", "variableToValue": { ... } } // becomes ${per2.*} | ||
| ] | ||
| } | ||
| ] | ||
| } | ||
| ``` | ||
|
|
||
| For full, runnable POST requests see the mock JSON files in [src/test/java/org/patinanetwork/patchats/email/mocks/]. | ||
|
|
||
| ### Response | ||
|
|
||
| Sending is **best-effort**: one failed message never blocks the others, so the response tells you the outcome of _each_ message. | ||
|
|
||
| ```jsonc | ||
| // 200 OK | ||
| { | ||
| "success": true, | ||
| "message": "Sent 2 of 2 emails", // human-readable summary | ||
| "payload": { | ||
| "sent": 2, // how many messages went out | ||
| "failed": 0, // how many failed | ||
| "results": [ | ||
| // one entry per message, in request order | ||
| { | ||
| "recipients": ["ann@example.com", "bob@example.com"], | ||
| "sent": true, | ||
| "error": null, | ||
| }, | ||
| { "recipients": ["cara@example.com"], "sent": false, "error": "..." }, | ||
| // ^ false + a reason if that one failed | ||
| ], | ||
| }, | ||
| } | ||
| ``` | ||
|
|
||
| **Placeholders.** Reference message-level `variables` un-prefixed (`${month}`), recipient vars as `${per1.*}` / `${per2.*}`. Behaviour when a key is missing depends on the syntax: | ||
|
|
||
| | Placeholder | If missing | | ||
| | -------------- | --------------------------------------- | | ||
| | `${x}` | **fails the message** (error names `x`) | | ||
| | `${x:default}` | uses `default` | | ||
| | `${x:}` | uses empty string | | ||
|
|
||
| A solo message has no `per2.*`, so any `${per2.*}` placeholder must use a default or | ||
| the message fails. | ||
|
|
||
| Invalid requests (bad email, empty `messages`, a message with 0 or >2 recipients, blank | ||
| subject/body) are rejected with **400** in the standard `ApiResponder` envelope before anything is | ||
| sent. | ||
|
|
||
| ## Testing the endpoint manually (Postman) | ||
|
|
||
| To hit the endpoint locally with a real request: | ||
|
|
||
| 1. Install the **Postman** extension to the workspace | ||
| 2. Create a **New HTTP Request**. | ||
| 3. Set the method to **POST** and the URL to `localhost:8080/api/email/send`. | ||
| 4. Under **Body**, choose **raw**, then select **JSON** from the format dropdown. | ||
| 5. Write a request body that matches `SendEmailRequest` | ||
| ([dto/SendEmailRequest.java](../src/main/java/org/patinanetwork/patchats/email/dto/SendEmailRequest.java)). | ||
| or paste a mock JSON in | ||
| [src/test/java/org/patinanetwork/patchats/email/mocks/](../src/test/java/org/patinanetwork/patchats/email/mocks/). | ||
| 6. Start the app with `just dev` in a terminal (the `dev` profile logs emails instead of sending, so no real SMTP is needed). | ||
| 7. Press **Send** and check the response payload | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
27 changes: 27 additions & 0 deletions
27
src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| package org.patinanetwork.patchats.common.web; | ||
|
|
||
| import java.util.stream.Collectors; | ||
| import org.patinanetwork.patchats.common.dto.ApiResponder; | ||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.validation.FieldError; | ||
| import org.springframework.web.bind.MethodArgumentNotValidException; | ||
| import org.springframework.web.bind.annotation.ExceptionHandler; | ||
| import org.springframework.web.bind.annotation.RestControllerAdvice; | ||
|
|
||
| /** Maps framework exceptions to the standard {@link ApiResponder} envelope. */ | ||
| @RestControllerAdvice | ||
| public class ApiExceptionHandler { | ||
|
|
||
| @ExceptionHandler(MethodArgumentNotValidException.class) | ||
| public ResponseEntity<ApiResponder<Void>> handleValidation(final MethodArgumentNotValidException ex) { | ||
| final String details = ex.getBindingResult().getFieldErrors().stream() | ||
| .map(this::formatError) | ||
| .collect(Collectors.joining("; ")); | ||
| final String message = details.isBlank() ? "Validation failed" : details; | ||
| return ResponseEntity.badRequest().body(ApiResponder.failure(message)); | ||
| } | ||
|
|
||
| private String formatError(final FieldError error) { | ||
| return error.getField() + " " + error.getDefaultMessage(); | ||
| } | ||
| } |
36 changes: 36 additions & 0 deletions
36
src/main/java/org/patinanetwork/patchats/email/EmailController.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| package org.patinanetwork.patchats.email; | ||
|
|
||
| import io.micrometer.core.annotation.Timed; | ||
| import io.swagger.v3.oas.annotations.Operation; | ||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||
| import jakarta.validation.Valid; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.patinanetwork.patchats.common.dto.ApiResponder; | ||
| import org.patinanetwork.patchats.email.dto.SendEmailRequest; | ||
| import org.patinanetwork.patchats.email.dto.SendEmailResponse; | ||
| import org.springframework.boot.context.properties.EnableConfigurationProperties; | ||
| 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; | ||
|
|
||
| /** REST endpoints for sending templated plain-text emails. */ | ||
| @RestController | ||
| @RequestMapping("/api/email") | ||
| @Tag(name = "Email") | ||
| @Timed(value = "controller.execution") | ||
| @EnableConfigurationProperties(EmailProperties.class) | ||
| @RequiredArgsConstructor | ||
| public class EmailController { | ||
|
|
||
| private final EmailService emailService; | ||
|
|
||
| @Operation(summary = "Send one or more templated plain-text emails") | ||
| @PostMapping("/send") | ||
| public ResponseEntity<ApiResponder<SendEmailResponse>> send(@Valid @RequestBody final SendEmailRequest request) { | ||
| final SendEmailResponse response = emailService.send(request); | ||
| final String message = "Sent %d of %d emails".formatted(response.sent(), response.sent() + response.failed()); | ||
| return ResponseEntity.ok(ApiResponder.success(message, response)); | ||
| } | ||
| } |
26 changes: 26 additions & 0 deletions
26
src/main/java/org/patinanetwork/patchats/email/EmailProperties.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| package org.patinanetwork.patchats.email; | ||
|
|
||
| import lombok.Getter; | ||
| import lombok.Setter; | ||
| import org.springframework.boot.context.properties.ConfigurationProperties; | ||
|
|
||
| /** Email sender configuration, bound from {@code app.email.*}. */ | ||
| @ConfigurationProperties(prefix = "app.email") | ||
| @Getter | ||
| @Setter | ||
| public class EmailProperties { | ||
|
|
||
| /** The verified From address, e.g. {@code coffeechats@patinanetwork.org}. */ | ||
| private String from; | ||
|
|
||
| /** Optional display name shown alongside the From address. */ | ||
| private String fromName; | ||
|
|
||
| /** Builds the From header: {@code "Name <addr>"} when a display name is set, else the bare address. */ | ||
| public String getFromHeader() { | ||
| if (fromName == null || fromName.isBlank()) { | ||
| return from; | ||
| } | ||
| return fromName + " <" + from + ">"; | ||
| } | ||
| } |
12 changes: 12 additions & 0 deletions
12
src/main/java/org/patinanetwork/patchats/email/EmailSender.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| package org.patinanetwork.patchats.email; | ||
|
|
||
| /** Port for delivering a single, fully-rendered email. Implementations own the transport. */ | ||
| public interface EmailSender { | ||
|
|
||
| /** | ||
| * Delivers the given email. | ||
| * | ||
| * @throws org.springframework.mail.MailException if delivery fails | ||
| */ | ||
| void send(OutgoingEmail email); | ||
| } |
75 changes: 75 additions & 0 deletions
75
src/main/java/org/patinanetwork/patchats/email/EmailService.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| package org.patinanetwork.patchats.email; | ||
|
|
||
| import java.util.ArrayList; | ||
| import java.util.HashMap; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.Optional; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.patinanetwork.patchats.email.dto.SendEmailRequest; | ||
| import org.patinanetwork.patchats.email.dto.SendEmailResponse; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.util.StringUtils; | ||
|
|
||
| /** | ||
| * Renders the caller-supplied templates once per message and delivers each via the configured {@link EmailSender}. | ||
| * Best-effort: a render or delivery failure fails only that message and is reported in the result; the rest of the | ||
| * batch still goes out. | ||
| */ | ||
| @Service | ||
| @RequiredArgsConstructor | ||
| @Slf4j | ||
| public class EmailService { | ||
|
|
||
| private final TemplateRenderer renderer; | ||
| private final EmailSender sender; | ||
|
|
||
| public SendEmailResponse send(final SendEmailRequest request) { | ||
| final Optional<String> replyTo = Optional.ofNullable(request.replyTo()).filter(StringUtils::hasText); | ||
| final List<SendEmailResponse.MessageResult> results = new ArrayList<>(); | ||
| int sent = 0; | ||
| int failed = 0; | ||
|
|
||
| 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); | ||
| final String subject = renderer.render(request.subject(), variables); | ||
| final String body = renderer.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)); | ||
| sent++; | ||
| } catch (final RuntimeException ex) { | ||
| log.warn("Failed to send email to {}: {}", recipients, ex.getMessage()); | ||
| results.add(new SendEmailResponse.MessageResult(recipients, false, ex.getMessage())); | ||
| failed++; | ||
| } | ||
| } | ||
|
|
||
| return new SendEmailResponse(sent, failed, results); | ||
| } | ||
|
|
||
| /** | ||
| * Flattens a message's variables into one map: message-level variables un-prefixed, and each recipient's variables | ||
| * under a positional {@code per1.}/{@code per2.} prefix. | ||
| */ | ||
| private static Map<String, String> mergeVariables(final SendEmailRequest.Message message) { | ||
| final Map<String, String> merged = new HashMap<>(); | ||
| if (message.variables() != null) { | ||
| merged.putAll(message.variables()); | ||
| } | ||
| final List<SendEmailRequest.Recipient> recipients = message.recipients(); | ||
| for (int i = 0; i < recipients.size(); i++) { | ||
| final String prefix = "per" + (i + 1) + "."; | ||
| final Map<String, String> vars = recipients.get(i).variableToValue(); | ||
| if (vars != null) { | ||
| vars.forEach((key, value) -> merged.put(prefix + key, value)); | ||
| } | ||
| } | ||
| return merged; | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.