From a27408410f75daedfb3e47aa4e3869dc27320727 Mon Sep 17 00:00:00 2001 From: Isabella Lam Date: Tue, 9 Jun 2026 18:10:45 -0400 Subject: [PATCH 1/5] Emails: Set up email backend structure --- .example.env | 9 + docs/email-feature.md | 172 ++++++++++++++++++ pom.xml | 10 +- .../api/auth/security/SecurityConfig.java | 28 ++- .../common/web/ApiExceptionHandler.java | 27 +++ .../patchats/email/EmailController.java | 36 ++++ .../patchats/email/EmailProperties.java | 26 +++ .../patchats/email/EmailSender.java | 12 ++ .../patchats/email/EmailService.java | 75 ++++++++ .../patchats/email/LoggingEmailSender.java | 22 +++ .../patchats/email/OutgoingEmail.java | 10 + .../patchats/email/SmtpEmailSender.java | 31 ++++ .../patchats/email/TemplateRenderer.java | 27 +++ .../patchats/email/dto/SendEmailRequest.java | 28 +++ .../patchats/email/dto/SendEmailResponse.java | 10 + src/main/resources/application.yml | 16 +- .../patchats/email/EmailControllerTest.java | 58 ++++++ .../patchats/email/EmailServiceTest.java | 87 +++++++++ .../patchats/email/SmtpEmailSenderTest.java | 50 +++++ .../patchats/email/TemplateRendererTest.java | 37 ++++ 20 files changed, 762 insertions(+), 9 deletions(-) create mode 100644 docs/email-feature.md create mode 100644 src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/EmailController.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/EmailProperties.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/EmailSender.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/EmailService.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/LoggingEmailSender.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/OutgoingEmail.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/SmtpEmailSender.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/TemplateRenderer.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/dto/SendEmailRequest.java create mode 100644 src/main/java/org/patinanetwork/patchats/email/dto/SendEmailResponse.java create mode 100644 src/test/java/org/patinanetwork/patchats/email/EmailControllerTest.java create mode 100644 src/test/java/org/patinanetwork/patchats/email/EmailServiceTest.java create mode 100644 src/test/java/org/patinanetwork/patchats/email/SmtpEmailSenderTest.java create mode 100644 src/test/java/org/patinanetwork/patchats/email/TemplateRendererTest.java diff --git a/.example.env b/.example.env index 4ccf743..dbdd07c 100644 --- a/.example.env +++ b/.example.env @@ -7,3 +7,12 @@ DATABASE_USER=postgres DATABASE_PASSWORD=enterpasswordhere # With the example values, this gets combined inside of the application.properties to make # jdbc://postgresql://localhost:5432/codebloom?user=postgres&password=enterpasswordhere + +# SMTP — consumed by spring.mail.* in non-dev profiles (the dev profile logs instead of sending) +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USERNAME=apikey +SMTP_PASSWORD=enterpasswordhere +# The verified From sender (a real, monitored mailbox on a domain you control) +EMAIL_FROM=coffeechats@patinanetwork.org +EMAIL_FROM_NAME=PatChats diff --git a/docs/email-feature.md b/docs/email-feature.md new file mode 100644 index 0000000..9d34fd5 --- /dev/null +++ b/docs/email-feature.md @@ -0,0 +1,172 @@ +# Email feature (backend) + +How the backend `email` domain sends transactional email (e.g. monthly pairing notifications). +Read this before touching the feature so new code lands in the right place and keeps the API +contract stable. + +The frontend POSTs **who** to email plus the **content** (a subject + body template and the +variables to merge in); the backend interpolates and sends over SMTP. v1 is **plain-text only**; +HTML is a deliberate, isolated future extension. This is the first **domain-first +(package-by-feature)** module in the backend — the analogue of the frontend's +`features//` (see `js/docs/frontend-structure.md`): a flat feature package that grows +into sub-packages only when it needs to, with the one external concern (SMTP transport) isolated +behind a port. + +## The shape + +``` +org.patinanetwork.patchats.email/ + EmailController.java POST /api/email/send → ResponseEntity> + 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 to (1–2), subject, body, Optional replyTo + EmailProperties.java @ConfigurationProperties("app.email") → from, fromName + dto/ + SendEmailRequest.java subject, body, replyTo?, List messages + Message(Map variables?, List recipients) // 1–2 + Recipient(String email, Map variables) + SendEmailResponse.java int sent, int failed, List + MessageResult(List 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. + +## Decisions (with rationale) + +1. **Domain-first package, flat until it grows.** One `email` package with flat classes + a + `dto/` sub-package, not a layered `controller/`/`service/`/`repository/` split. Mirrors the + frontend's domain-first ethos; avoids ceremony for a single endpoint. +2. **`spring-boot-starter-mail` for transport.** Idiomatic Spring Boot: autoconfigures + `JavaMailSender` from `spring.mail.*`, gives a health indicator, least boilerplate. We drop + the unused raw `com.sun.mail:jakarta.mail` + `pop3` (pop3 is receive-only) in favour of the + starter. Wrapped behind `EmailSender` so the domain never imports Spring mail types directly. +3. **Synchronous send (v1).** The request blocks until SMTP completes and returns honest + `sent`/`failed` results, so the frontend can show real delivery confirmation. The port lets us + move to `@Async`/outbox later without changing the API. Acceptable because sends are + admin-triggered and moderate-volume. +4. **Caller-supplied templates.** The client sends the subject + body template strings; there is + no server-side template registry. Keeps email copy with the people who write it. +5. **Logic-less `${}` interpolation.** Variables are merged with Spring's + `PropertyPlaceholderHelper` (named-placeholder replacement only). Because templates come from + the client, a full engine (Thymeleaf/FreeMarker) would be a server-side template injection + (SSTI) hole — logic-less substitution removes that class of bug, with zero new dependencies. +6. **Messages of 1–2 recipients; per-recipient variables, positional namespace.** A request + carries `messages[]`; each message is **one email to one or two recipients, both on `To`**. + Two recipients means a pairing introduction — they *should* see each other and can reply-all. + Because the two share one rendered body, each recipient's own variables are exposed + positionally as `${recipient1.*}` / `${recipient2.*}`, alongside an optional message-level + `variables` map (referenced un-prefixed) for shared values. Capped at 2 (pairings, not lists). +7. **Best-effort, per-message results.** SMTP sends cannot be transactionally rolled back, so one + failure never aborts the batch. The response reports each message's outcome (`sent` + `error`) + and totals, letting the frontend retry just the failures. +8. **Minimal validation.** `@NotBlank`/`@Email` plus the structural `@Size(1,2)` on recipients + and `@NotEmpty` on messages; we lean on `JavaMailSender`'s address parsing + subject encoding + for baseline header-injection safety. Heavier hardening (explicit CR/LF guards, batch/size + caps) is deferred — see Future work. +9. **Per-placeholder fallback encoded in the template.** Using the value-separator syntax: + `${x}` (no default) → that message **fails** if `x` is missing; `${x:default text}` → falls + back to the default; `${x:}` (empty default) → blanks out. This expresses "required vs. + optional vs. defaulted" per placeholder without any custom policy engine, and works the same + for namespaced keys (`${recipient2.linkedIn:N/A}`). +10. **Fixed server `From` + optional `replyTo`.** `From` is a single server-configured, real, + monitored address (config value), so SPF/DKIM/DMARC stay aligned and the client cannot spoof + the sender. Callers may set `replyTo` to route replies to a coordinator. +11. **Admin-only.** Sending is an admin action and an abuse magnet, so the endpoint targets the + admin role (mirrors the frontend `RequireAdmin` guard). See the security note below for the + current enforcement caveat. +12. **Logs now, audit-table-ready.** v1 records outcomes via `@Slf4j` only. The per-message + result objects are already shaped like future audit rows, so adding a DB table later is a + contained change (see Future work). +13. **HTML is future, and isolated.** Plain text now via `SimpleMailMessage`. Adding HTML means a + `contentType`/`html` field on `OutgoingEmail` and a `MimeMessageHelper` branch inside + `SmtpEmailSender` — no change to the controller, service, or renderer. + +## API contract + +`POST /api/email/send` — subject and body are templates; substitution runs **once per message**. + +```jsonc +{ + "subject": "You've been paired, ${recipient1.firstName}!", + "body": "Hi ${recipient1.firstName} and ${recipient2.firstName} — you're paired for ${month}!\nSay hi: ${recipient1.firstName} (${recipient1.linkedIn:N/A}) ↔ ${recipient2.firstName} (${recipient2.linkedIn:N/A}).\n${intro:Looking forward to your chat!}", + "replyTo": "coordinator@patinanetwork.org", // optional + "messages": [ + { // a pair → ONE email to both, both on To + "variables": { "month": "July" }, // shared (message-level), referenced un-prefixed + "recipients": [ + { "email": "ann@example.com", "variables": { "firstName": "Ann", "linkedIn": "https://linkedin.com/in/ann" } }, + { "email": "bob@example.com", "variables": { "firstName": "Bob" } } // recipient2.linkedIn → "N/A" + ] + }, + { // a solo notice → ONE email to one recipient + "recipients": [ { "email": "cara@example.com", "variables": { "firstName": "Cara" } } ] + } + ] +} +``` + +```jsonc +// 200 OK — results are per message, in request order +{ "success": true, "message": "Sent 2 of 2 emails", + "payload": { "sent": 2, "failed": 0, + "results": [ { "recipients": ["ann@example.com","bob@example.com"], "sent": true, "error": null }, + { "recipients": ["cara@example.com"], "sent": true, "error": null } ] } } +``` + +**Variable resolution per message.** The service builds one merged map: message-level +`variables` un-prefixed, the first recipient's under `recipient1.`, the second's under +`recipient2.`. Then for each placeholder: + +| Placeholder | If the key is present | If the key is missing | +|-------------|-----------------------|-----------------------| +| `${x}` | substitutes the value | **fails the message** (reason names `x`) | +| `${x:default}` | substitutes the value | substitutes `default` | +| `${x:}` | substitutes the value | substitutes empty string | + +A solo message exposes only `recipient1.*`; any `${recipient2.*}` reference must therefore carry +a default (or use a solo-specific template), otherwise 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. + +## Security & config + +- **Authorization.** The default/production security chain requires the admin role on + `POST /api/email/**` (`.hasRole("ADMIN")`). ⚠️ Authentication (OAuth2 login / roles) is not yet + wired, so no `SecurityContext` is populated — the rule therefore **fails closed**: every caller + is denied until the auth domain authenticates admins. The `dev` profile uses a separate chain + that permits everything, so local development can exercise the endpoint without auth. Other + endpoints remain open for now, matching prior behaviour. +- **SMTP config** lives in `application.yml` under `spring.mail.*` + (host/port/username/password/`properties.mail.smtp.auth`+`starttls.enable`), sourced from + `${SMTP_*}` environment variables via `spring-dotenv`. Maven resource filtering uses `@@` + delimiters, so Spring's `${...}` placeholders pass through untouched. Secrets are managed with + SOPS, never committed in plaintext. +- **Sender identity** is configured under `app.email` (`from`, `from-name`) and bound by + `EmailProperties`. Use a real, monitored address on a domain authenticated with the SMTP + provider. +- **Dev/test.** The `dev` profile binds `LoggingEmailSender`, so local development needs no real + SMTP server. Integration tests use GreenMail (in-process fake SMTP) to assert real MIME output. + +## Future work (designed for, not built) + +- **HTML emails** — `contentType`/`html` on `OutgoingEmail` + a `MimeMessageHelper` branch in + `SmtpEmailSender`; renderer and contract unchanged. +- **DB audit table** — a Flyway migration + a recorder fed the existing `MessageResult` objects; + no change to send logic. +- **Asynchronous / outbox delivery** — move sending off the request thread behind the existing + `EmailSender` port if volume grows. +- **Rate limiting** — Bucket4j (already a dependency) for per-admin/per-window caps. +- **Hardened validation** — explicit CR/LF & control-char rejection, batch and body-size caps. +- **Frontend** — a `features/email/api/useSendEmail.ts` mutation hook + Zod schema mirroring this + contract. diff --git a/pom.xml b/pom.xml index 849dcd8..bd6eddd 100644 --- a/pom.xml +++ b/pom.xml @@ -147,14 +147,14 @@ 5.3.0 - com.sun.mail - jakarta.mail - 2.0.1 + org.springframework.boot + spring-boot-starter-mail - com.sun.mail - pop3 + com.icegreen + greenmail-junit5 2.0.1 + test com.auth0 diff --git a/src/main/java/org/patinanetwork/patchats/api/auth/security/SecurityConfig.java b/src/main/java/org/patinanetwork/patchats/api/auth/security/SecurityConfig.java index a6538ab..3b9bab1 100644 --- a/src/main/java/org/patinanetwork/patchats/api/auth/security/SecurityConfig.java +++ b/src/main/java/org/patinanetwork/patchats/api/auth/security/SecurityConfig.java @@ -2,16 +2,38 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.web.SecurityFilterChain; @Configuration public class SecurityConfig { + + /** + * Default/production chain. Sending email is admin-only. + * + *

NOTE: authentication (OAuth2 login / roles) is not yet wired, so this rule currently fails closed — every + * caller is denied because no security context is populated. Once the auth domain authenticates admins, the rule + * begins to discriminate admins from other users. Other endpoints remain open for now, matching prior behaviour. + */ + @Bean + @Profile("!dev") + SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception { + return http.csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(auth -> auth.requestMatchers(HttpMethod.POST, "/api/email/**") + .hasRole("ADMIN") + .anyRequest() + .permitAll()) + .build(); + } + + /** Local-dev chain: everything is permitted so the email endpoint can be exercised without auth. */ @Bean - SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + @Profile("dev") + SecurityFilterChain devSecurityFilterChain(final HttpSecurity http) throws Exception { return http.csrf(csrf -> csrf.disable()) - .authorizeHttpRequests(auth -> - auth.requestMatchers("/api/**").permitAll().anyRequest().permitAll()) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) .build(); } } diff --git a/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java b/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java new file mode 100644 index 0000000..302916b --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java @@ -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> 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(); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/email/EmailController.java b/src/main/java/org/patinanetwork/patchats/email/EmailController.java new file mode 100644 index 0000000..9b03a31 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/EmailController.java @@ -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> 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)); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/email/EmailProperties.java b/src/main/java/org/patinanetwork/patchats/email/EmailProperties.java new file mode 100644 index 0000000..7518e85 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/EmailProperties.java @@ -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 "} when a display name is set, else the bare address. */ + public String getFromHeader() { + if (fromName == null || fromName.isBlank()) { + return from; + } + return fromName + " <" + from + ">"; + } +} diff --git a/src/main/java/org/patinanetwork/patchats/email/EmailSender.java b/src/main/java/org/patinanetwork/patchats/email/EmailSender.java new file mode 100644 index 0000000..cedf20c --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/EmailSender.java @@ -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); +} diff --git a/src/main/java/org/patinanetwork/patchats/email/EmailService.java b/src/main/java/org/patinanetwork/patchats/email/EmailService.java new file mode 100644 index 0000000..40b39c9 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/EmailService.java @@ -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 replyTo = Optional.ofNullable(request.replyTo()).filter(StringUtils::hasText); + final List results = new ArrayList<>(); + int sent = 0; + int failed = 0; + + for (final SendEmailRequest.Message message : request.messages()) { + final List recipients = message.recipients().stream() + .map(SendEmailRequest.Recipient::email) + .toList(); + try { + final Map 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 recipient1.}/{@code recipient2.} prefix. + */ + private static Map mergeVariables(final SendEmailRequest.Message message) { + final Map merged = new HashMap<>(); + if (message.variables() != null) { + merged.putAll(message.variables()); + } + final List recipients = message.recipients(); + for (int i = 0; i < recipients.size(); i++) { + final String prefix = "recipient" + (i + 1) + "."; + final Map vars = recipients.get(i).name_to_variable(); + if (vars != null) { + vars.forEach((key, value) -> merged.put(prefix + key, value)); + } + } + return merged; + } +} diff --git a/src/main/java/org/patinanetwork/patchats/email/LoggingEmailSender.java b/src/main/java/org/patinanetwork/patchats/email/LoggingEmailSender.java new file mode 100644 index 0000000..870918d --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/LoggingEmailSender.java @@ -0,0 +1,22 @@ +package org.patinanetwork.patchats.email; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +/** Dev-profile sender that logs the rendered email instead of delivering it, so local dev needs no SMTP server. */ +@Component +@Profile("dev") +@Slf4j +public class LoggingEmailSender implements EmailSender { + + @Override + public void send(final OutgoingEmail email) { + log.info( + "[DEV] Email NOT delivered. to={} replyTo={} subject={}\n{}", + email.to(), + email.replyTo().orElse(""), + email.subject(), + email.body()); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/email/OutgoingEmail.java b/src/main/java/org/patinanetwork/patchats/email/OutgoingEmail.java new file mode 100644 index 0000000..e0785cb --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/OutgoingEmail.java @@ -0,0 +1,10 @@ +package org.patinanetwork.patchats.email; + +import java.util.List; +import java.util.Optional; + +/** + * A fully-rendered email ready to send: 1–2 recipients on the To line, a plain-text subject and body, and an optional + * Reply-To. The From address is supplied by the sender from configuration. + */ +public record OutgoingEmail(List to, String subject, String body, Optional replyTo) {} diff --git a/src/main/java/org/patinanetwork/patchats/email/SmtpEmailSender.java b/src/main/java/org/patinanetwork/patchats/email/SmtpEmailSender.java new file mode 100644 index 0000000..96b478f --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/SmtpEmailSender.java @@ -0,0 +1,31 @@ +package org.patinanetwork.patchats.email; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Component; + +/** + * Delivers email over SMTP via Spring's {@link JavaMailSender}. Plain-text only for now; adding HTML means switching to + * {@code MimeMessageHelper} here, with no change to callers. + */ +@Component +@Profile("!dev") +@RequiredArgsConstructor +public class SmtpEmailSender implements EmailSender { + + private final JavaMailSender mailSender; + private final EmailProperties properties; + + @Override + public void send(final OutgoingEmail email) { + final SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(properties.getFromHeader()); + message.setTo(email.to().toArray(new String[0])); + message.setSubject(email.subject()); + message.setText(email.body()); + email.replyTo().ifPresent(message::setReplyTo); + mailSender.send(message); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/email/TemplateRenderer.java b/src/main/java/org/patinanetwork/patchats/email/TemplateRenderer.java new file mode 100644 index 0000000..22f67b8 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/TemplateRenderer.java @@ -0,0 +1,27 @@ +package org.patinanetwork.patchats.email; + +import java.util.Map; +import org.springframework.stereotype.Component; +import org.springframework.util.PropertyPlaceholderHelper; + +/** + * Renders caller-supplied templates with logic-less {@code ${}} substitution (no expression evaluation, so client + * templates cannot trigger server-side template injection). + * + *

Placeholder semantics: {@code ${x}} is required and throws when missing, {@code ${x:default}} falls back to the + * default, and {@code ${x:}} blanks out. + */ +@Component +public class TemplateRenderer { + + private static final PropertyPlaceholderHelper HELPER = new PropertyPlaceholderHelper("${", "}", ":", null, false); + + /** + * Substitutes placeholders in {@code template} using {@code variables}. + * + * @throws IllegalArgumentException if a required placeholder has neither a value nor a default + */ + public String render(final String template, final Map variables) { + return HELPER.replacePlaceholders(template, variables::get); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/email/dto/SendEmailRequest.java b/src/main/java/org/patinanetwork/patchats/email/dto/SendEmailRequest.java new file mode 100644 index 0000000..38afef7 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/dto/SendEmailRequest.java @@ -0,0 +1,28 @@ +package org.patinanetwork.patchats.email.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import java.util.List; +import java.util.Map; + +/** + * 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( + @NotBlank String subject, + @NotBlank String body, + @Email String replyTo, + @NotEmpty @Valid List messages) { + + /** One outgoing email addressed to 1–2 recipients who share the rendered body. */ + public record Message( + Map variables, + @NotEmpty @Size(max = 2) @Valid List recipients) {} + + /** A single recipient and the variables personalised to them (exposed as {@code recipientN.*}). */ + public record Recipient(@NotBlank @Email String email, Map name_to_variable) {} +} diff --git a/src/main/java/org/patinanetwork/patchats/email/dto/SendEmailResponse.java b/src/main/java/org/patinanetwork/patchats/email/dto/SendEmailResponse.java new file mode 100644 index 0000000..33d72fb --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/dto/SendEmailResponse.java @@ -0,0 +1,10 @@ +package org.patinanetwork.patchats.email.dto; + +import java.util.List; + +/** Result of a send request: totals plus a per-message outcome, in request order. */ +public record SendEmailResponse(int sent, int failed, List results) { + + /** Outcome for a single message (the email to its 1–2 recipients). */ + public record MessageResult(List recipients, boolean sent, String error) {} +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1e62527..aaf8f2c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,7 +1,21 @@ spring: autoconfigure: exclude: org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration + mail: + host: ${SMTP_HOST:} + port: ${SMTP_PORT:587} + username: ${SMTP_USERNAME:} + password: ${SMTP_PASSWORD:} + properties: + mail: + smtp: + auth: true + starttls: + enable: true app: commit: - sha: "@commit.sha@" \ No newline at end of file + sha: "@commit.sha@" + email: + from: ${EMAIL_FROM:coffeechats@patinanetwork.org} + from-name: ${EMAIL_FROM_NAME:PatChats} \ No newline at end of file diff --git a/src/test/java/org/patinanetwork/patchats/email/EmailControllerTest.java b/src/test/java/org/patinanetwork/patchats/email/EmailControllerTest.java new file mode 100644 index 0000000..1f143a8 --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/email/EmailControllerTest.java @@ -0,0 +1,58 @@ +package org.patinanetwork.patchats.email; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.patinanetwork.patchats.common.web.ApiExceptionHandler; +import org.patinanetwork.patchats.email.dto.SendEmailResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(EmailController.class) +@AutoConfigureMockMvc(addFilters = false) +@Import(ApiExceptionHandler.class) +class EmailControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private EmailService emailService; + + @Test + void returnsOkAndApiResponderOnSuccess() throws Exception { + when(emailService.send(any())) + .thenReturn(new SendEmailResponse( + 1, 0, List.of(new SendEmailResponse.MessageResult(List.of("a@x.com"), true, null)))); + + mockMvc.perform( + post("/api/email/send") + .contentType(MediaType.APPLICATION_JSON) + .content( + "{\"subject\":\"S\",\"body\":\"B\",\"messages\":[{\"recipients\":[{\"email\":\"a@x.com\"}]}]}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.payload.sent").value(1)); + } + + @Test + void returnsBadRequestOnInvalidEmail() throws Exception { + mockMvc.perform( + post("/api/email/send") + .contentType(MediaType.APPLICATION_JSON) + .content( + "{\"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 new file mode 100644 index 0000000..1465963 --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/email/EmailServiceTest.java @@ -0,0 +1,87 @@ +package org.patinanetwork.patchats.email; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.patinanetwork.patchats.email.dto.SendEmailRequest; +import org.patinanetwork.patchats.email.dto.SendEmailResponse; +import org.springframework.mail.MailSendException; + +class EmailServiceTest { + + private final EmailSender sender = mock(EmailSender.class); + private final EmailService service = new EmailService(new TemplateRenderer(), sender); + + @Test + void sendsPairAsOneEmailWithNamespacedVariables() { + final SendEmailRequest request = new SendEmailRequest( + "Hi ${recipient1.firstName} & ${recipient2.firstName}", + "Paired for ${month}. LinkedIn: ${recipient2.linkedIn:N/A}", + null, + List.of(new SendEmailRequest.Message( + Map.of("month", "July"), + List.of( + new SendEmailRequest.Recipient("ann@x.com", Map.of("firstName", "Ann")), + new SendEmailRequest.Recipient("bob@x.com", Map.of("firstName", "Bob")))))); + + final SendEmailResponse response = service.send(request); + + assertEquals(1, response.sent()); + assertEquals(0, response.failed()); + + final ArgumentCaptor captor = ArgumentCaptor.forClass(OutgoingEmail.class); + verify(sender).send(captor.capture()); + final OutgoingEmail email = captor.getValue(); + assertEquals(List.of("ann@x.com", "bob@x.com"), email.to()); + assertEquals("Hi Ann & Bob", email.subject()); + assertTrue(email.body().contains("Paired for July")); + assertTrue(email.body().contains("N/A")); + } + + @Test + void missingRequiredVariableFailsOnlyThatMessage() { + final SendEmailRequest request = new SendEmailRequest( + "Hi ${recipient1.firstName}", + "Body", + null, + List.of( + new SendEmailRequest.Message( + null, + List.of(new SendEmailRequest.Recipient("good@x.com", Map.of("firstName", "Ann")))), + new SendEmailRequest.Message( + null, List.of(new SendEmailRequest.Recipient("bad@x.com", Map.of()))))); + + final SendEmailResponse response = service.send(request); + + assertEquals(1, response.sent()); + assertEquals(1, response.failed()); + assertTrue(response.results().get(0).sent()); + assertFalse(response.results().get(1).sent()); + } + + @Test + void smtpFailureIsReportedPerMessage() { + doThrow(new MailSendException("smtp down")).when(sender).send(any()); + final SendEmailRequest request = new SendEmailRequest( + "S", + "B", + null, + List.of(new SendEmailRequest.Message( + null, List.of(new SendEmailRequest.Recipient("a@x.com", Map.of()))))); + + final SendEmailResponse response = service.send(request); + + assertEquals(0, response.sent()); + assertEquals(1, response.failed()); + assertFalse(response.results().get(0).sent()); + } +} diff --git a/src/test/java/org/patinanetwork/patchats/email/SmtpEmailSenderTest.java b/src/test/java/org/patinanetwork/patchats/email/SmtpEmailSenderTest.java new file mode 100644 index 0000000..46051af --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/email/SmtpEmailSenderTest.java @@ -0,0 +1,50 @@ +package org.patinanetwork.patchats.email; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.icegreen.greenmail.configuration.GreenMailConfiguration; +import com.icegreen.greenmail.junit5.GreenMailExtension; +import com.icegreen.greenmail.util.GreenMailUtil; +import com.icegreen.greenmail.util.ServerSetupTest; +import jakarta.mail.internet.MimeMessage; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.springframework.mail.javamail.JavaMailSenderImpl; + +class SmtpEmailSenderTest { + + @RegisterExtension + static final GreenMailExtension GREEN_MAIL = + new GreenMailExtension(ServerSetupTest.SMTP).withConfiguration(GreenMailConfiguration.aConfig()); + + @Test + void deliversPlainTextToBothRecipients() throws Exception { + final JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); + mailSender.setHost("localhost"); + mailSender.setPort(GREEN_MAIL.getSmtp().getPort()); + + final EmailProperties properties = new EmailProperties(); + properties.setFrom("coffeechats@patinanetwork.org"); + properties.setFromName("PatChats"); + + final SmtpEmailSender sender = new SmtpEmailSender(mailSender, properties); + sender.send(new OutgoingEmail( + List.of("ann@example.com", "bob@example.com"), + "You're paired!", + "Hi Ann and Bob.", + Optional.of("coordinator@patinanetwork.org"))); + + assertTrue(GREEN_MAIL.waitForIncomingEmail(5000, 2)); + final MimeMessage[] received = GREEN_MAIL.getReceivedMessages(); + assertEquals(2, received.length); + + final MimeMessage message = received[0]; + assertEquals("You're paired!", message.getSubject()); + assertEquals("PatChats ", message.getFrom()[0].toString()); + assertEquals("coordinator@patinanetwork.org", message.getReplyTo()[0].toString()); + assertTrue(GreenMailUtil.getBody(message).contains("Hi Ann and Bob.")); + } +} diff --git a/src/test/java/org/patinanetwork/patchats/email/TemplateRendererTest.java b/src/test/java/org/patinanetwork/patchats/email/TemplateRendererTest.java new file mode 100644 index 0000000..d3d190c --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/email/TemplateRendererTest.java @@ -0,0 +1,37 @@ +package org.patinanetwork.patchats.email; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class TemplateRendererTest { + + private final TemplateRenderer renderer = new TemplateRenderer(); + + @Test + void substitutesPresentValues() { + assertEquals("Hi Ann", renderer.render("Hi ${name}", Map.of("name", "Ann"))); + } + + @Test + void usesDefaultWhenMissing() { + assertEquals("Hi friend", renderer.render("Hi ${name:friend}", Map.of())); + } + + @Test + void blanksWhenEmptyDefault() { + assertEquals("Hi ", renderer.render("Hi ${name:}", Map.of())); + } + + @Test + void throwsWhenRequiredPlaceholderMissing() { + assertThrows(IllegalArgumentException.class, () -> renderer.render("Hi ${name}", Map.of())); + } + + @Test + void resolvesNamespacedKeys() { + assertEquals("Bob", renderer.render("${recipient2.firstName}", Map.of("recipient2.firstName", "Bob"))); + } +} From a55aae30a4615b6840c6ea33ffa050fed500dade Mon Sep 17 00:00:00 2001 From: Isabella Lam Date: Wed, 17 Jun 2026 18:09:17 -0400 Subject: [PATCH 2/5] Update email-feature.md --- docs/email-feature.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/email-feature.md b/docs/email-feature.md index 9d34fd5..60b16d8 100644 --- a/docs/email-feature.md +++ b/docs/email-feature.md @@ -6,16 +6,15 @@ contract stable. The frontend POSTs **who** to email plus the **content** (a subject + body template and the variables to merge in); the backend interpolates and sends over SMTP. v1 is **plain-text only**; -HTML is a deliberate, isolated future extension. This is the first **domain-first -(package-by-feature)** module in the backend — the analogue of the frontend's -`features//` (see `js/docs/frontend-structure.md`): a flat feature package that grows -into sub-packages only when it needs to, with the one external concern (SMTP transport) isolated -behind a port. +As of now, the emails are composed of plain-text only. It is intended to implement HTML in +future emails. To ease this future extension, the email module was developed with a +**domain-first (package-by-feature)** structure. This is similar to the front-end architecture(see `js/docs/frontend-structure.md`): a flat feature package that grows +into sub-packages only when it needs to, with the one external concern (SMTP transport) isolated behind a port. ## The shape ``` -org.patinanetwork.patchats.email/ +src/main/java/org/patinanetwork/patchats/email/ EmailController.java POST /api/email/send → ResponseEntity> EmailService.java orchestration: per message build vars → render → send → collect results (+ @Slf4j) EmailSender.java PORT: void send(OutgoingEmail email) From 6279b00f567fa5d57e046c5a9968c8a8d9f6c402 Mon Sep 17 00:00:00 2001 From: Isabella Lam Date: Thu, 18 Jun 2026 00:03:04 -0400 Subject: [PATCH 3/5] Condense email-feature.md, rename variable, add JSON examples. --- docs/email-feature.md | 184 ++++++------------ .../patchats/email/EmailService.java | 2 +- .../patchats/email/dto/SendEmailRequest.java | 8 +- .../email/mocks/error-email-example.json | 20 ++ .../email/mocks/pair-email-example.json | 18 ++ .../mocks/single-user-email-example.json | 21 ++ 6 files changed, 130 insertions(+), 123 deletions(-) create mode 100644 src/test/java/org/patinanetwork/patchats/email/mocks/error-email-example.json create mode 100644 src/test/java/org/patinanetwork/patchats/email/mocks/pair-email-example.json create mode 100644 src/test/java/org/patinanetwork/patchats/email/mocks/single-user-email-example.json diff --git a/docs/email-feature.md b/docs/email-feature.md index 60b16d8..75c0849 100644 --- a/docs/email-feature.md +++ b/docs/email-feature.md @@ -1,21 +1,22 @@ # Email feature (backend) How the backend `email` domain sends transactional email (e.g. monthly pairing notifications). -Read this before touching the feature so new code lands in the right place and keeps the API -contract stable. -The frontend POSTs **who** to email plus the **content** (a subject + body template and the -variables to merge in); the backend interpolates and sends over SMTP. v1 is **plain-text only**; -As of now, the emails are composed of plain-text only. It is intended to implement HTML in -future emails. To ease this future extension, the email module was developed with a -**domain-first (package-by-feature)** structure. This is similar to the front-end architecture(see `js/docs/frontend-structure.md`): a flat feature package that grows -into sub-packages only when it needs to, with the one external concern (SMTP transport) isolated behind a port. +**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> + EmailController.java POST /api/email/send → ResponseEntity> 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) @@ -26,7 +27,7 @@ src/main/java/org/patinanetwork/patchats/email/ dto/ SendEmailRequest.java subject, body, replyTo?, List messages Message(Map variables?, List recipients) // 1–2 - Recipient(String email, Map variables) + Recipient(String email, Map variableToValue) SendEmailResponse.java int sent, int failed, List MessageResult(List recipients, boolean sent, String error?) @@ -34,138 +35,79 @@ 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. - -## Decisions (with rationale) - -1. **Domain-first package, flat until it grows.** One `email` package with flat classes + a - `dto/` sub-package, not a layered `controller/`/`service/`/`repository/` split. Mirrors the - frontend's domain-first ethos; avoids ceremony for a single endpoint. -2. **`spring-boot-starter-mail` for transport.** Idiomatic Spring Boot: autoconfigures - `JavaMailSender` from `spring.mail.*`, gives a health indicator, least boilerplate. We drop - the unused raw `com.sun.mail:jakarta.mail` + `pop3` (pop3 is receive-only) in favour of the - starter. Wrapped behind `EmailSender` so the domain never imports Spring mail types directly. -3. **Synchronous send (v1).** The request blocks until SMTP completes and returns honest - `sent`/`failed` results, so the frontend can show real delivery confirmation. The port lets us - move to `@Async`/outbox later without changing the API. Acceptable because sends are - admin-triggered and moderate-volume. -4. **Caller-supplied templates.** The client sends the subject + body template strings; there is - no server-side template registry. Keeps email copy with the people who write it. -5. **Logic-less `${}` interpolation.** Variables are merged with Spring's - `PropertyPlaceholderHelper` (named-placeholder replacement only). Because templates come from - the client, a full engine (Thymeleaf/FreeMarker) would be a server-side template injection - (SSTI) hole — logic-less substitution removes that class of bug, with zero new dependencies. -6. **Messages of 1–2 recipients; per-recipient variables, positional namespace.** A request - carries `messages[]`; each message is **one email to one or two recipients, both on `To`**. - Two recipients means a pairing introduction — they *should* see each other and can reply-all. - Because the two share one rendered body, each recipient's own variables are exposed - positionally as `${recipient1.*}` / `${recipient2.*}`, alongside an optional message-level - `variables` map (referenced un-prefixed) for shared values. Capped at 2 (pairings, not lists). -7. **Best-effort, per-message results.** SMTP sends cannot be transactionally rolled back, so one - failure never aborts the batch. The response reports each message's outcome (`sent` + `error`) - and totals, letting the frontend retry just the failures. -8. **Minimal validation.** `@NotBlank`/`@Email` plus the structural `@Size(1,2)` on recipients - and `@NotEmpty` on messages; we lean on `JavaMailSender`'s address parsing + subject encoding - for baseline header-injection safety. Heavier hardening (explicit CR/LF guards, batch/size - caps) is deferred — see Future work. -9. **Per-placeholder fallback encoded in the template.** Using the value-separator syntax: - `${x}` (no default) → that message **fails** if `x` is missing; `${x:default text}` → falls - back to the default; `${x:}` (empty default) → blanks out. This expresses "required vs. - optional vs. defaulted" per placeholder without any custom policy engine, and works the same - for namespaced keys (`${recipient2.linkedIn:N/A}`). -10. **Fixed server `From` + optional `replyTo`.** `From` is a single server-configured, real, - monitored address (config value), so SPF/DKIM/DMARC stay aligned and the client cannot spoof - the sender. Callers may set `replyTo` to route replies to a coordinator. -11. **Admin-only.** Sending is an admin action and an abuse magnet, so the endpoint targets the - admin role (mirrors the frontend `RequireAdmin` guard). See the security note below for the - current enforcement caveat. -12. **Logs now, audit-table-ready.** v1 records outcomes via `@Slf4j` only. The per-message - result objects are already shaped like future audit rows, so adding a DB table later is a - contained change (see Future work). -13. **HTML is future, and isolated.** Plain text now via `SimpleMailMessage`. Adding HTML means a - `contentType`/`html` field on `OutgoingEmail` and a `MimeMessageHelper` branch inside - `SmtpEmailSender` — no change to the controller, service, or renderer. +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": "You've been paired, ${recipient1.firstName}!", - "body": "Hi ${recipient1.firstName} and ${recipient2.firstName} — you're paired for ${month}!\nSay hi: ${recipient1.firstName} (${recipient1.linkedIn:N/A}) ↔ ${recipient2.firstName} (${recipient2.linkedIn:N/A}).\n${intro:Looking forward to your chat!}", - "replyTo": "coordinator@patinanetwork.org", // optional + "subject": "...", // ${} template, required + "body": "...", // ${} template, required + "replyTo": "...", // optional; the "From" address is set in .env "messages": [ - { // a pair → ONE email to both, both on To - "variables": { "month": "July" }, // shared (message-level), referenced un-prefixed - "recipients": [ - { "email": "ann@example.com", "variables": { "firstName": "Ann", "linkedIn": "https://linkedin.com/in/ann" } }, - { "email": "bob@example.com", "variables": { "firstName": "Bob" } } // recipient2.linkedIn → "N/A" + { + "variables": { ... }, // optional shared vars → referenced un-prefixed, e.g. ${month} + "recipients": [ // 1 or 2 recipients + { "email": "...", "variableToValue": { ... } }, // becomes ${recipient1.*} + { "email": "...", "variableToValue": { ... } } // becomes ${recipient2.*} ] - }, - { // a solo notice → ONE email to one recipient - "recipients": [ { "email": "cara@example.com", "variables": { "firstName": "Cara" } } ] } ] } ``` +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 — results are per message, in request order -{ "success": true, "message": "Sent 2 of 2 emails", - "payload": { "sent": 2, "failed": 0, - "results": [ { "recipients": ["ann@example.com","bob@example.com"], "sent": true, "error": null }, - { "recipients": ["cara@example.com"], "sent": true, "error": null } ] } } +// 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 + ] + } +} ``` -**Variable resolution per message.** The service builds one merged map: message-level -`variables` un-prefixed, the first recipient's under `recipient1.`, the second's under -`recipient2.`. Then for each placeholder: +**Placeholders.** Reference message-level `variables` un-prefixed (`${month}`), recipient vars as `${recipient1.*}` / `${recipient2.*}`. Behaviour when a key is missing depends on the syntax: -| Placeholder | If the key is present | If the key is missing | -|-------------|-----------------------|-----------------------| -| `${x}` | substitutes the value | **fails the message** (reason names `x`) | -| `${x:default}` | substitutes the value | substitutes `default` | -| `${x:}` | substitutes the value | substitutes empty string | +| Placeholder | If missing | +|----------------|------------| +| `${x}` | **fails the message** (error names `x`) | +| `${x:default}` | uses `default` | +| `${x:}` | uses empty string | -A solo message exposes only `recipient1.*`; any `${recipient2.*}` reference must therefore carry -a default (or use a solo-specific template), otherwise the message fails. +A solo message has no `recipient2.*`, so any `${recipient2.*}` 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. -## Security & config - -- **Authorization.** The default/production security chain requires the admin role on - `POST /api/email/**` (`.hasRole("ADMIN")`). ⚠️ Authentication (OAuth2 login / roles) is not yet - wired, so no `SecurityContext` is populated — the rule therefore **fails closed**: every caller - is denied until the auth domain authenticates admins. The `dev` profile uses a separate chain - that permits everything, so local development can exercise the endpoint without auth. Other - endpoints remain open for now, matching prior behaviour. -- **SMTP config** lives in `application.yml` under `spring.mail.*` - (host/port/username/password/`properties.mail.smtp.auth`+`starttls.enable`), sourced from - `${SMTP_*}` environment variables via `spring-dotenv`. Maven resource filtering uses `@@` - delimiters, so Spring's `${...}` placeholders pass through untouched. Secrets are managed with - SOPS, never committed in plaintext. -- **Sender identity** is configured under `app.email` (`from`, `from-name`) and bound by - `EmailProperties`. Use a real, monitored address on a domain authenticated with the SMTP - provider. -- **Dev/test.** The `dev` profile binds `LoggingEmailSender`, so local development needs no real - SMTP server. Integration tests use GreenMail (in-process fake SMTP) to assert real MIME output. - -## Future work (designed for, not built) - -- **HTML emails** — `contentType`/`html` on `OutgoingEmail` + a `MimeMessageHelper` branch in - `SmtpEmailSender`; renderer and contract unchanged. -- **DB audit table** — a Flyway migration + a recorder fed the existing `MessageResult` objects; - no change to send logic. -- **Asynchronous / outbox delivery** — move sending off the request thread behind the existing - `EmailSender` port if volume grows. -- **Rate limiting** — Bucket4j (already a dependency) for per-admin/per-window caps. -- **Hardened validation** — explicit CR/LF & control-char rejection, batch and body-size caps. -- **Frontend** — a `features/email/api/useSendEmail.ts` mutation hook + Zod schema mirroring this - contract. +## 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 diff --git a/src/main/java/org/patinanetwork/patchats/email/EmailService.java b/src/main/java/org/patinanetwork/patchats/email/EmailService.java index 40b39c9..aefe44e 100644 --- a/src/main/java/org/patinanetwork/patchats/email/EmailService.java +++ b/src/main/java/org/patinanetwork/patchats/email/EmailService.java @@ -65,7 +65,7 @@ private static Map mergeVariables(final SendEmailRequest.Message final List recipients = message.recipients(); for (int i = 0; i < recipients.size(); i++) { final String prefix = "recipient" + (i + 1) + "."; - final Map vars = recipients.get(i).name_to_variable(); + final Map vars = recipients.get(i).variableToValue(); if (vars != null) { vars.forEach((key, value) -> merged.put(prefix + key, value)); } 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 38afef7..bf33262 100644 --- a/src/main/java/org/patinanetwork/patchats/email/dto/SendEmailRequest.java +++ b/src/main/java/org/patinanetwork/patchats/email/dto/SendEmailRequest.java @@ -24,5 +24,11 @@ public record Message( @NotEmpty @Size(max = 2) @Valid List recipients) {} /** A single recipient and the variables personalised to them (exposed as {@code recipientN.*}). */ - public record Recipient(@NotBlank @Email String email, Map name_to_variable) {} + public record Recipient(@NotBlank @Email String email, Map variableToValue) {} } + +/** + * Simple Example JSON (see `src/test/java/org/patinanetwork/patchats/email/mocks` for more examples): { "subject": + * "Pair testing subject", "body": "Pair email testing", "replyTo": "patchats@example.com", "messages" : [ { + * "recipients" : [{"email" : "anna@example.com"}, { "email" : "bob@example.com"}] } ] } + */ diff --git a/src/test/java/org/patinanetwork/patchats/email/mocks/error-email-example.json b/src/test/java/org/patinanetwork/patchats/email/mocks/error-email-example.json new file mode 100644 index 0000000..95d098f --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/email/mocks/error-email-example.json @@ -0,0 +1,20 @@ +{ + "subject": "Too Many Receipients", + "body": "The maximum number of receipients is 2. This message has 3 recipients and should fail.", + "replyTo": "patchats@example.com", + "messages" : [ + { + "recipients" : [ + { + "email" : "anna@example.com" + }, + { + "email" : "bob@example.com" + }, + { + "email" : "cara@example.com" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/test/java/org/patinanetwork/patchats/email/mocks/pair-email-example.json b/src/test/java/org/patinanetwork/patchats/email/mocks/pair-email-example.json new file mode 100644 index 0000000..9bbde0e --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/email/mocks/pair-email-example.json @@ -0,0 +1,18 @@ +{ + "subject": "You've been paired, ${recipient1.firstName}!", + "body": "Hi ${recipient1.firstName} and ${recipient2.firstName} — you're paired for ${month}!\nSay hi: ${recipient1.firstName} (${recipient1.linkedIn:N/A}) ${recipient2.firstName} (${recipient2.linkedIn:N/A}).\n${intro:Looking forward to your chat!}", + "replyTo": "coordinator@patinanetwork.org", + "messages": [ + { + "variables": { "month": "July" }, + "recipients": [ + { "email": "anna@example.com", "variableToValue": { "firstName": "Anna", "linkedIn": "https://linkedin.com" } }, + { "email": "bob@example.com", "variableToValue": { "firstName": "Bob" } } + ] + }, + { + "recipients": [ { "email": "cara@example.com", "variableToValue": { "firstName": "Cara" } }, + {"email": "dan@example.com", "variableToValue": { "firstName": "Dan" }} ] + } + ] +} \ No newline at end of file diff --git a/src/test/java/org/patinanetwork/patchats/email/mocks/single-user-email-example.json b/src/test/java/org/patinanetwork/patchats/email/mocks/single-user-email-example.json new file mode 100644 index 0000000..5d2fba7 --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/email/mocks/single-user-email-example.json @@ -0,0 +1,21 @@ +{ + "subject": "Single User Testing Subject", + "body": "This should send two separate emails to two different users. ${recipient1.name}", + "replyTo": "patchats@example.com", + "messages" : [ + { + "recipients" : [ + { + "email" : "anna@example.com", "variableToValue": {"name": "Anna"} + } + ] + }, + { + "recipients" : [ + { + "email" : "bob@example.com", "variableToValue": {"name": "Bob"} + } + ] + } + ] +} \ No newline at end of file From d520b634b145f482a899b84d354c615efeddb1a2 Mon Sep 17 00:00:00 2001 From: Isabella Lam Date: Thu, 18 Jun 2026 19:59:48 -0400 Subject: [PATCH 4/5] Change 'recipient' variables to 'rec' --- docs/email-feature.md | 8 ++++---- .../org/patinanetwork/patchats/email/EmailService.java | 2 +- .../patchats/email/dto/SendEmailRequest.java | 2 +- .../patinanetwork/patchats/email/EmailServiceTest.java | 6 +++--- .../patchats/email/TemplateRendererTest.java | 2 +- .../patchats/email/mocks/pair-email-example.json | 4 ++-- .../patchats/email/mocks/single-user-email-example.json | 2 +- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/email-feature.md b/docs/email-feature.md index 75c0849..101c94c 100644 --- a/docs/email-feature.md +++ b/docs/email-feature.md @@ -51,8 +51,8 @@ The SMTP transport is the only piece that touches the outside world, so it sits { "variables": { ... }, // optional shared vars → referenced un-prefixed, e.g. ${month} "recipients": [ // 1 or 2 recipients - { "email": "...", "variableToValue": { ... } }, // becomes ${recipient1.*} - { "email": "...", "variableToValue": { ... } } // becomes ${recipient2.*} + { "email": "...", "variableToValue": { ... } }, // becomes ${rec1.*} + { "email": "...", "variableToValue": { ... } } // becomes ${rec2.*} ] } ] @@ -82,7 +82,7 @@ Sending is **best-effort**: one failed message never blocks the others, so the r } ``` -**Placeholders.** Reference message-level `variables` un-prefixed (`${month}`), recipient vars as `${recipient1.*}` / `${recipient2.*}`. Behaviour when a key is missing depends on the syntax: +**Placeholders.** Reference message-level `variables` un-prefixed (`${month}`), recipient vars as `${rec1.*}` / `${rec2.*}`. Behaviour when a key is missing depends on the syntax: | Placeholder | If missing | |----------------|------------| @@ -90,7 +90,7 @@ Sending is **best-effort**: one failed message never blocks the others, so the r | `${x:default}` | uses `default` | | `${x:}` | uses empty string | -A solo message has no `recipient2.*`, so any `${recipient2.*}` placeholder must use a default or +A solo message has no `rec2.*`, so any `${rec2.*}` placeholder must use a default or the message fails. Invalid requests (bad email, empty `messages`, a message with 0 or >2 recipients, blank diff --git a/src/main/java/org/patinanetwork/patchats/email/EmailService.java b/src/main/java/org/patinanetwork/patchats/email/EmailService.java index aefe44e..d76f997 100644 --- a/src/main/java/org/patinanetwork/patchats/email/EmailService.java +++ b/src/main/java/org/patinanetwork/patchats/email/EmailService.java @@ -64,7 +64,7 @@ private static Map mergeVariables(final SendEmailRequest.Message } final List recipients = message.recipients(); for (int i = 0; i < recipients.size(); i++) { - final String prefix = "recipient" + (i + 1) + "."; + final String prefix = "rec" + (i + 1) + "."; final Map vars = recipients.get(i).variableToValue(); if (vars != null) { vars.forEach((key, value) -> merged.put(prefix + key, value)); 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 bf33262..e85d3cc 100644 --- a/src/main/java/org/patinanetwork/patchats/email/dto/SendEmailRequest.java +++ b/src/main/java/org/patinanetwork/patchats/email/dto/SendEmailRequest.java @@ -23,7 +23,7 @@ public record Message( Map variables, @NotEmpty @Size(max = 2) @Valid List recipients) {} - /** A single recipient and the variables personalised to them (exposed as {@code recipientN.*}). */ + /** A single recipient and the variables personalised to them (exposed as {@code recN.*}). */ public record Recipient(@NotBlank @Email String email, Map variableToValue) {} } diff --git a/src/test/java/org/patinanetwork/patchats/email/EmailServiceTest.java b/src/test/java/org/patinanetwork/patchats/email/EmailServiceTest.java index 1465963..3573ce2 100644 --- a/src/test/java/org/patinanetwork/patchats/email/EmailServiceTest.java +++ b/src/test/java/org/patinanetwork/patchats/email/EmailServiceTest.java @@ -24,8 +24,8 @@ class EmailServiceTest { @Test void sendsPairAsOneEmailWithNamespacedVariables() { final SendEmailRequest request = new SendEmailRequest( - "Hi ${recipient1.firstName} & ${recipient2.firstName}", - "Paired for ${month}. LinkedIn: ${recipient2.linkedIn:N/A}", + "Hi ${rec1.firstName} & ${rec2.firstName}", + "Paired for ${month}. LinkedIn: ${rec2.linkedIn:N/A}", null, List.of(new SendEmailRequest.Message( Map.of("month", "July"), @@ -50,7 +50,7 @@ void sendsPairAsOneEmailWithNamespacedVariables() { @Test void missingRequiredVariableFailsOnlyThatMessage() { final SendEmailRequest request = new SendEmailRequest( - "Hi ${recipient1.firstName}", + "Hi ${rec1.firstName}", "Body", null, List.of( diff --git a/src/test/java/org/patinanetwork/patchats/email/TemplateRendererTest.java b/src/test/java/org/patinanetwork/patchats/email/TemplateRendererTest.java index d3d190c..ad8e70e 100644 --- a/src/test/java/org/patinanetwork/patchats/email/TemplateRendererTest.java +++ b/src/test/java/org/patinanetwork/patchats/email/TemplateRendererTest.java @@ -32,6 +32,6 @@ void throwsWhenRequiredPlaceholderMissing() { @Test void resolvesNamespacedKeys() { - assertEquals("Bob", renderer.render("${recipient2.firstName}", Map.of("recipient2.firstName", "Bob"))); + assertEquals("Bob", renderer.render("${rec2.firstName}", Map.of("rec2.firstName", "Bob"))); } } diff --git a/src/test/java/org/patinanetwork/patchats/email/mocks/pair-email-example.json b/src/test/java/org/patinanetwork/patchats/email/mocks/pair-email-example.json index 9bbde0e..aa201a0 100644 --- a/src/test/java/org/patinanetwork/patchats/email/mocks/pair-email-example.json +++ b/src/test/java/org/patinanetwork/patchats/email/mocks/pair-email-example.json @@ -1,6 +1,6 @@ { - "subject": "You've been paired, ${recipient1.firstName}!", - "body": "Hi ${recipient1.firstName} and ${recipient2.firstName} — you're paired for ${month}!\nSay hi: ${recipient1.firstName} (${recipient1.linkedIn:N/A}) ${recipient2.firstName} (${recipient2.linkedIn:N/A}).\n${intro:Looking forward to your chat!}", + "subject": "You've been paired, ${rec1.firstName}!", + "body": "Hi ${rec1.firstName} and ${rec2.firstName} — you're paired for ${month}!\nSay hi: ${rec1.firstName} (${rec1.linkedIn:N/A}) ${rec2.firstName} (${rec2.linkedIn:N/A}).\n${rec2.intro:Looking forward to your chat!}", "replyTo": "coordinator@patinanetwork.org", "messages": [ { diff --git a/src/test/java/org/patinanetwork/patchats/email/mocks/single-user-email-example.json b/src/test/java/org/patinanetwork/patchats/email/mocks/single-user-email-example.json index 5d2fba7..f75adba 100644 --- a/src/test/java/org/patinanetwork/patchats/email/mocks/single-user-email-example.json +++ b/src/test/java/org/patinanetwork/patchats/email/mocks/single-user-email-example.json @@ -1,6 +1,6 @@ { "subject": "Single User Testing Subject", - "body": "This should send two separate emails to two different users. ${recipient1.name}", + "body": "This should send two separate emails to two different users. ${rec1.name}", "replyTo": "patchats@example.com", "messages" : [ { From a8a058e466d48fb7e6a17924f8c4f25244f96d59 Mon Sep 17 00:00:00 2001 From: Isabella Lam Date: Fri, 19 Jun 2026 16:11:19 -0400 Subject: [PATCH 5/5] Change rec to per. Run prettier on JSON file --- .sops.yaml | 2 +- .vscode/settings.json | 4 +- docs/email-feature.md | 50 +++++++++++-------- .../patchats/email/EmailService.java | 4 +- .../patchats/email/dto/SendEmailRequest.java | 2 +- src/main/resources/application.yml | 2 +- .../patchats/email/EmailServiceTest.java | 6 +-- .../patchats/email/TemplateRendererTest.java | 2 +- .../email/mocks/error-email-example.json | 34 ++++++------- .../email/mocks/pair-email-example.json | 39 +++++++++++---- .../mocks/single-user-email-example.json | 36 ++++++------- 11 files changed, 103 insertions(+), 78 deletions(-) diff --git a/.sops.yaml b/.sops.yaml index 9329aa5..a01e5b2 100644 --- a/.sops.yaml +++ b/.sops.yaml @@ -4,5 +4,5 @@ stores: creation_rules: - path_regex: secrets-rw\.yaml azure_keyvault: "https://sops-master.vault.azure.net/keys/sops-key/" - - path_regex: secrets-ro\.yaml + - path_regex: secrets-ro\.yaml azure_keyvault: "https://sops-ro.vault.azure.net/keys/sops-ro-key/" diff --git a/.vscode/settings.json b/.vscode/settings.json index 962f28f..f1bae3b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "java.checkstyle.configuration": "./checkstyle.xml" -} \ No newline at end of file + "java.checkstyle.configuration": "./checkstyle.xml" +} diff --git a/docs/email-feature.md b/docs/email-feature.md index 101c94c..1e963ca 100644 --- a/docs/email-feature.md +++ b/docs/email-feature.md @@ -2,14 +2,14 @@ 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 +**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 +it needs to, with the single external dependency (SMTP) hidden behind a _port_ (a Java interface whose implementation can be swapped). ## The shape @@ -42,6 +42,7 @@ The SMTP transport is the only piece that touches the outside world, so it sits `POST /api/email/send` — subject and body are templates; substitution runs **once per message**. ### Request + ```jsonc { "subject": "...", // ${} template, required @@ -51,46 +52,51 @@ The SMTP transport is the only piece that touches the outside world, so it sits { "variables": { ... }, // optional shared vars → referenced un-prefixed, e.g. ${month} "recipients": [ // 1 or 2 recipients - { "email": "...", "variableToValue": { ... } }, // becomes ${rec1.*} - { "email": "...", "variableToValue": { ... } } // becomes ${rec2.*} + { "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/]. +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. +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 + "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": "..." } + "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 `${rec1.*}` / `${rec2.*}`. Behaviour when a key is missing depends on the syntax: +**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 | -|----------------|------------| +| Placeholder | If missing | +| -------------- | --------------------------------------- | | `${x}` | **fails the message** (error names `x`) | -| `${x:default}` | uses `default` | -| `${x:}` | uses empty string | +| `${x:default}` | uses `default` | +| `${x:}` | uses empty string | -A solo message has no `rec2.*`, so any `${rec2.*}` placeholder must use a default or +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 @@ -107,7 +113,7 @@ To hit the endpoint locally with a real request: 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 + 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 +7. Press **Send** and check the response payload diff --git a/src/main/java/org/patinanetwork/patchats/email/EmailService.java b/src/main/java/org/patinanetwork/patchats/email/EmailService.java index d76f997..47c98c1 100644 --- a/src/main/java/org/patinanetwork/patchats/email/EmailService.java +++ b/src/main/java/org/patinanetwork/patchats/email/EmailService.java @@ -55,7 +55,7 @@ public SendEmailResponse send(final SendEmailRequest request) { /** * Flattens a message's variables into one map: message-level variables un-prefixed, and each recipient's variables - * under a positional {@code recipient1.}/{@code recipient2.} prefix. + * under a positional {@code per1.}/{@code per2.} prefix. */ private static Map mergeVariables(final SendEmailRequest.Message message) { final Map merged = new HashMap<>(); @@ -64,7 +64,7 @@ private static Map mergeVariables(final SendEmailRequest.Message } final List recipients = message.recipients(); for (int i = 0; i < recipients.size(); i++) { - final String prefix = "rec" + (i + 1) + "."; + final String prefix = "per" + (i + 1) + "."; final Map vars = recipients.get(i).variableToValue(); if (vars != null) { vars.forEach((key, value) -> merged.put(prefix + key, value)); 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 e85d3cc..79c293c 100644 --- a/src/main/java/org/patinanetwork/patchats/email/dto/SendEmailRequest.java +++ b/src/main/java/org/patinanetwork/patchats/email/dto/SendEmailRequest.java @@ -23,7 +23,7 @@ public record Message( Map variables, @NotEmpty @Size(max = 2) @Valid List recipients) {} - /** A single recipient and the variables personalised to them (exposed as {@code recN.*}). */ + /** A single recipient and the variables personalised to them (exposed as {@code perN.*}). */ public record Recipient(@NotBlank @Email String email, Map variableToValue) {} } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index aaf8f2c..1677530 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -18,4 +18,4 @@ app: sha: "@commit.sha@" email: from: ${EMAIL_FROM:coffeechats@patinanetwork.org} - from-name: ${EMAIL_FROM_NAME:PatChats} \ No newline at end of file + from-name: ${EMAIL_FROM_NAME:PatChats} diff --git a/src/test/java/org/patinanetwork/patchats/email/EmailServiceTest.java b/src/test/java/org/patinanetwork/patchats/email/EmailServiceTest.java index 3573ce2..772544d 100644 --- a/src/test/java/org/patinanetwork/patchats/email/EmailServiceTest.java +++ b/src/test/java/org/patinanetwork/patchats/email/EmailServiceTest.java @@ -24,8 +24,8 @@ class EmailServiceTest { @Test void sendsPairAsOneEmailWithNamespacedVariables() { final SendEmailRequest request = new SendEmailRequest( - "Hi ${rec1.firstName} & ${rec2.firstName}", - "Paired for ${month}. LinkedIn: ${rec2.linkedIn:N/A}", + "Hi ${per1.firstName} & ${per2.firstName}", + "Paired for ${month}. LinkedIn: ${per2.linkedIn:N/A}", null, List.of(new SendEmailRequest.Message( Map.of("month", "July"), @@ -50,7 +50,7 @@ void sendsPairAsOneEmailWithNamespacedVariables() { @Test void missingRequiredVariableFailsOnlyThatMessage() { final SendEmailRequest request = new SendEmailRequest( - "Hi ${rec1.firstName}", + "Hi ${per1.firstName}", "Body", null, List.of( diff --git a/src/test/java/org/patinanetwork/patchats/email/TemplateRendererTest.java b/src/test/java/org/patinanetwork/patchats/email/TemplateRendererTest.java index ad8e70e..0317b77 100644 --- a/src/test/java/org/patinanetwork/patchats/email/TemplateRendererTest.java +++ b/src/test/java/org/patinanetwork/patchats/email/TemplateRendererTest.java @@ -32,6 +32,6 @@ void throwsWhenRequiredPlaceholderMissing() { @Test void resolvesNamespacedKeys() { - assertEquals("Bob", renderer.render("${rec2.firstName}", Map.of("rec2.firstName", "Bob"))); + assertEquals("Bob", renderer.render("${per2.firstName}", Map.of("per2.firstName", "Bob"))); } } diff --git a/src/test/java/org/patinanetwork/patchats/email/mocks/error-email-example.json b/src/test/java/org/patinanetwork/patchats/email/mocks/error-email-example.json index 95d098f..c8dcd88 100644 --- a/src/test/java/org/patinanetwork/patchats/email/mocks/error-email-example.json +++ b/src/test/java/org/patinanetwork/patchats/email/mocks/error-email-example.json @@ -1,20 +1,20 @@ { - "subject": "Too Many Receipients", - "body": "The maximum number of receipients is 2. This message has 3 recipients and should fail.", - "replyTo": "patchats@example.com", - "messages" : [ + "subject": "Too Many Receipients", + "body": "The maximum number of receipients is 2. This message has 3 recipients and should fail.", + "replyTo": "patchats@example.com", + "messages": [ + { + "recipients": [ { - "recipients" : [ - { - "email" : "anna@example.com" - }, - { - "email" : "bob@example.com" - }, - { - "email" : "cara@example.com" - } - ] + "email": "anna@example.com" + }, + { + "email": "bob@example.com" + }, + { + "email": "cara@example.com" } - ] -} \ No newline at end of file + ] + } + ] +} diff --git a/src/test/java/org/patinanetwork/patchats/email/mocks/pair-email-example.json b/src/test/java/org/patinanetwork/patchats/email/mocks/pair-email-example.json index aa201a0..8ad8bb6 100644 --- a/src/test/java/org/patinanetwork/patchats/email/mocks/pair-email-example.json +++ b/src/test/java/org/patinanetwork/patchats/email/mocks/pair-email-example.json @@ -1,18 +1,35 @@ { - "subject": "You've been paired, ${rec1.firstName}!", - "body": "Hi ${rec1.firstName} and ${rec2.firstName} — you're paired for ${month}!\nSay hi: ${rec1.firstName} (${rec1.linkedIn:N/A}) ${rec2.firstName} (${rec2.linkedIn:N/A}).\n${rec2.intro:Looking forward to your chat!}", - "replyTo": "coordinator@patinanetwork.org", + "subject": "You've been paired, ${per1.firstName}!", + "body": "Hi ${per1.firstName} and ${per2.firstName} — you're paired for ${month}!\nSay hi: ${per1.firstName} (${per1.linkedIn:N/A}) ${per2.firstName} (${per2.linkedIn:N/A}).\n${per2.intro:Looking forward to your chat!}", + "replyTo": "coordinator@patinanetwork.org", "messages": [ - { - "variables": { "month": "July" }, + { + "variables": { "month": "July" }, "recipients": [ - { "email": "anna@example.com", "variableToValue": { "firstName": "Anna", "linkedIn": "https://linkedin.com" } }, - { "email": "bob@example.com", "variableToValue": { "firstName": "Bob" } } + { + "email": "anna@example.com", + "variableToValue": { + "firstName": "Anna", + "linkedIn": "https://linkedin.com" + } + }, + { + "email": "bob@example.com", + "variableToValue": { "firstName": "Bob" } + } ] }, - { - "recipients": [ { "email": "cara@example.com", "variableToValue": { "firstName": "Cara" } }, - {"email": "dan@example.com", "variableToValue": { "firstName": "Dan" }} ] + { + "recipients": [ + { + "email": "cara@example.com", + "variableToValue": { "firstName": "Cara" } + }, + { + "email": "dan@example.com", + "variableToValue": { "firstName": "Dan" } + } + ] } ] -} \ No newline at end of file +} diff --git a/src/test/java/org/patinanetwork/patchats/email/mocks/single-user-email-example.json b/src/test/java/org/patinanetwork/patchats/email/mocks/single-user-email-example.json index f75adba..ccbcf8c 100644 --- a/src/test/java/org/patinanetwork/patchats/email/mocks/single-user-email-example.json +++ b/src/test/java/org/patinanetwork/patchats/email/mocks/single-user-email-example.json @@ -1,21 +1,23 @@ { - "subject": "Single User Testing Subject", - "body": "This should send two separate emails to two different users. ${rec1.name}", - "replyTo": "patchats@example.com", - "messages" : [ + "subject": "Single User Testing Subject", + "body": "This should send two separate emails to two different users. ${per1.name}", + "replyTo": "patchats@example.com", + "messages": [ + { + "recipients": [ { - "recipients" : [ - { - "email" : "anna@example.com", "variableToValue": {"name": "Anna"} - } - ] - }, + "email": "anna@example.com", + "variableToValue": { "name": "Anna" } + } + ] + }, + { + "recipients": [ { - "recipients" : [ - { - "email" : "bob@example.com", "variableToValue": {"name": "Bob"} - } - ] + "email": "bob@example.com", + "variableToValue": { "name": "Bob" } } - ] -} \ No newline at end of file + ] + } + ] +}