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/.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 new file mode 100644 index 0000000..1e963ca --- /dev/null +++ b/docs/email-feature.md @@ -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> + 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 variableToValue) + 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. + +## 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 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..47c98c1 --- /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 per1.}/{@code per2.} 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 = "per" + (i + 1) + "."; + final Map vars = recipients.get(i).variableToValue(); + 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..79c293c --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/email/dto/SendEmailRequest.java @@ -0,0 +1,34 @@ +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 perN.*}). */ + 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/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..1677530 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} 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..772544d --- /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 ${per1.firstName} & ${per2.firstName}", + "Paired for ${month}. LinkedIn: ${per2.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 ${per1.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..0317b77 --- /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("${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 new file mode 100644 index 0000000..c8dcd88 --- /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" + } + ] + } + ] +} 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..8ad8bb6 --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/email/mocks/pair-email-example.json @@ -0,0 +1,35 @@ +{ + "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" }, + "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" } + } + ] + } + ] +} 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..ccbcf8c --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/email/mocks/single-user-email-example.json @@ -0,0 +1,23 @@ +{ + "subject": "Single User Testing Subject", + "body": "This should send two separate emails to two different users. ${per1.name}", + "replyTo": "patchats@example.com", + "messages": [ + { + "recipients": [ + { + "email": "anna@example.com", + "variableToValue": { "name": "Anna" } + } + ] + }, + { + "recipients": [ + { + "email": "bob@example.com", + "variableToValue": { "name": "Bob" } + } + ] + } + ] +}