Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion .sops.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
4 changes: 2 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"java.checkstyle.configuration": "./checkstyle.xml"
}
"java.checkstyle.configuration": "./checkstyle.xml"
}
119 changes: 119 additions & 0 deletions docs/email-feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Email feature (backend)
Comment thread
isabellalam12 marked this conversation as resolved.

How the backend `email` domain sends transactional email (e.g. monthly pairing notifications).

**What it does.** The frontend POSTs _who_ to email plus the _content_ — a subject + body
template and the variables to fill in. The backend merges the variables into the templates and
sends the result over SMTP.

**How it's organised.** v1 is **plain-text only**; HTML is planned for later. So the module uses
a **domain-first (package-by-feature)** layout — the same idea as the frontend (see
`js/docs/frontend-structure.md`): one flat `email` package that only splits into sub-packages when
it needs to, with the single external dependency (SMTP) hidden behind a _port_ (a Java interface
whose implementation can be swapped).

## The shape

```
src/main/java/org/patinanetwork/patchats/email/
EmailController.java POST /api/email/send → ResponseEntity<ApiResponder<SendEmailResponse>>
EmailService.java orchestration: per message build vars → render → send → collect results (+ @Slf4j)
EmailSender.java PORT: void send(OutgoingEmail email)
SmtpEmailSender.java @Profile("!dev") — JavaMailSender + SimpleMailMessage (plain text)
LoggingEmailSender.java @Profile("dev") — renders + logs instead of sending (no real SMTP)
TemplateRenderer.java logic-less ${} interpolation (Spring PropertyPlaceholderHelper)
OutgoingEmail.java internal record: List<String> to (1–2), subject, body, Optional<String> replyTo
EmailProperties.java @ConfigurationProperties("app.email") → from, fromName
dto/
SendEmailRequest.java subject, body, replyTo?, List<Message> messages
Message(Map<String,String> variables?, List<Recipient> recipients) // 1–2
Recipient(String email, Map<String,String> variableToValue)
SendEmailResponse.java int sent, int failed, List<MessageResult>
MessageResult(List<String> recipients, boolean sent, String error?)

common/web/
ApiExceptionHandler.java @RestControllerAdvice: bean-validation errors → ApiResponder.failure (400)
```

The SMTP transport is the only piece that touches the outside world, so it sits behind `EmailSender`. That one seam buys three things: the dev profile swaps in a no-send logging implementation, integration tests inject a fake, and the future HTML switch is confined to the adapter.

## API contract

`POST /api/email/send` — subject and body are templates; substitution runs **once per message**.

### Request

```jsonc
{
"subject": "...", // ${} template, required
"body": "...", // ${} template, required
"replyTo": "...", // optional; the "From" address is set in .env
"messages": [
{
"variables": { ... }, // optional shared vars → referenced un-prefixed, e.g. ${month}
"recipients": [ // 1 or 2 recipients
{ "email": "...", "variableToValue": { ... } }, // becomes ${per1.*}
{ "email": "...", "variableToValue": { ... } } // becomes ${per2.*}
]
}
]
}
```

For full, runnable POST requests see the mock JSON files in [src/test/java/org/patinanetwork/patchats/email/mocks/].

### Response

Sending is **best-effort**: one failed message never blocks the others, so the response tells you the outcome of _each_ message.

```jsonc
// 200 OK
{
"success": true,
"message": "Sent 2 of 2 emails", // human-readable summary
"payload": {
"sent": 2, // how many messages went out
"failed": 0, // how many failed
"results": [
// one entry per message, in request order
{
"recipients": ["ann@example.com", "bob@example.com"],
"sent": true,
"error": null,
},
{ "recipients": ["cara@example.com"], "sent": false, "error": "..." },
// ^ false + a reason if that one failed
],
},
}
```

**Placeholders.** Reference message-level `variables` un-prefixed (`${month}`), recipient vars as `${per1.*}` / `${per2.*}`. Behaviour when a key is missing depends on the syntax:

| Placeholder | If missing |
| -------------- | --------------------------------------- |
| `${x}` | **fails the message** (error names `x`) |
| `${x:default}` | uses `default` |
| `${x:}` | uses empty string |

A solo message has no `per2.*`, so any `${per2.*}` placeholder must use a default or
the message fails.

Invalid requests (bad email, empty `messages`, a message with 0 or >2 recipients, blank
subject/body) are rejected with **400** in the standard `ApiResponder` envelope before anything is
sent.

## Testing the endpoint manually (Postman)

To hit the endpoint locally with a real request:

1. Install the **Postman** extension to the workspace
2. Create a **New HTTP Request**.
3. Set the method to **POST** and the URL to `localhost:8080/api/email/send`.
4. Under **Body**, choose **raw**, then select **JSON** from the format dropdown.
5. Write a request body that matches `SendEmailRequest`
([dto/SendEmailRequest.java](../src/main/java/org/patinanetwork/patchats/email/dto/SendEmailRequest.java)).
or paste a mock JSON in
[src/test/java/org/patinanetwork/patchats/email/mocks/](../src/test/java/org/patinanetwork/patchats/email/mocks/).
6. Start the app with `just dev` in a terminal (the `dev` profile logs emails instead of sending, so no real SMTP is needed).
7. Press **Send** and check the response payload
10 changes: 5 additions & 5 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -147,14 +147,14 @@
<version>5.3.0</version>
</dependency>
<dependency>
<groupId>com.sun.mail</groupId>
<artifactId>jakarta.mail</artifactId>
<version>2.0.1</version>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>com.sun.mail</groupId>
<artifactId>pop3</artifactId>
<groupId>com.icegreen</groupId>
<artifactId>greenmail-junit5</artifactId>
<version>2.0.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package org.patinanetwork.patchats.common.web;

import java.util.stream.Collectors;
import org.patinanetwork.patchats.common.dto.ApiResponder;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/** Maps framework exceptions to the standard {@link ApiResponder} envelope. */
@RestControllerAdvice
public class ApiExceptionHandler {

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponder<Void>> handleValidation(final MethodArgumentNotValidException ex) {
final String details = ex.getBindingResult().getFieldErrors().stream()
.map(this::formatError)
.collect(Collectors.joining("; "));
final String message = details.isBlank() ? "Validation failed" : details;
return ResponseEntity.badRequest().body(ApiResponder.failure(message));
}

private String formatError(final FieldError error) {
return error.getField() + " " + error.getDefaultMessage();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package org.patinanetwork.patchats.email;

import io.micrometer.core.annotation.Timed;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.patinanetwork.patchats.common.dto.ApiResponder;
import org.patinanetwork.patchats.email.dto.SendEmailRequest;
import org.patinanetwork.patchats.email.dto.SendEmailResponse;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/** REST endpoints for sending templated plain-text emails. */
@RestController
@RequestMapping("/api/email")
@Tag(name = "Email")
@Timed(value = "controller.execution")
@EnableConfigurationProperties(EmailProperties.class)
@RequiredArgsConstructor
public class EmailController {

private final EmailService emailService;

@Operation(summary = "Send one or more templated plain-text emails")
@PostMapping("/send")
public ResponseEntity<ApiResponder<SendEmailResponse>> send(@Valid @RequestBody final SendEmailRequest request) {
final SendEmailResponse response = emailService.send(request);
final String message = "Sent %d of %d emails".formatted(response.sent(), response.sent() + response.failed());
return ResponseEntity.ok(ApiResponder.success(message, response));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package org.patinanetwork.patchats.email;

import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;

/** Email sender configuration, bound from {@code app.email.*}. */
@ConfigurationProperties(prefix = "app.email")
@Getter
@Setter
public class EmailProperties {

/** The verified From address, e.g. {@code coffeechats@patinanetwork.org}. */
private String from;

/** Optional display name shown alongside the From address. */
private String fromName;

/** Builds the From header: {@code "Name <addr>"} when a display name is set, else the bare address. */
public String getFromHeader() {
if (fromName == null || fromName.isBlank()) {
return from;
}
return fromName + " <" + from + ">";
}
}
12 changes: 12 additions & 0 deletions src/main/java/org/patinanetwork/patchats/email/EmailSender.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package org.patinanetwork.patchats.email;

/** Port for delivering a single, fully-rendered email. Implementations own the transport. */
public interface EmailSender {

/**
* Delivers the given email.
*
* @throws org.springframework.mail.MailException if delivery fails
*/
void send(OutgoingEmail email);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package org.patinanetwork.patchats.email;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.patinanetwork.patchats.email.dto.SendEmailRequest;
import org.patinanetwork.patchats.email.dto.SendEmailResponse;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

/**
* Renders the caller-supplied templates once per message and delivers each via the configured {@link EmailSender}.
* Best-effort: a render or delivery failure fails only that message and is reported in the result; the rest of the
* batch still goes out.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class EmailService {

private final TemplateRenderer renderer;
private final EmailSender sender;

public SendEmailResponse send(final SendEmailRequest request) {
final Optional<String> replyTo = Optional.ofNullable(request.replyTo()).filter(StringUtils::hasText);
final List<SendEmailResponse.MessageResult> results = new ArrayList<>();
int sent = 0;
int failed = 0;

for (final SendEmailRequest.Message message : request.messages()) {
final List<String> recipients = message.recipients().stream()
.map(SendEmailRequest.Recipient::email)
.toList();
try {
final Map<String, String> variables = mergeVariables(message);
final String subject = renderer.render(request.subject(), variables);
final String body = renderer.render(request.body(), variables);
sender.send(new OutgoingEmail(recipients, subject, body, replyTo));
log.info("Sent email to {}", recipients);
results.add(new SendEmailResponse.MessageResult(recipients, true, null));
sent++;
} catch (final RuntimeException ex) {
log.warn("Failed to send email to {}: {}", recipients, ex.getMessage());
results.add(new SendEmailResponse.MessageResult(recipients, false, ex.getMessage()));
failed++;
}
}

return new SendEmailResponse(sent, failed, results);
}

/**
* Flattens a message's variables into one map: message-level variables un-prefixed, and each recipient's variables
* under a positional {@code per1.}/{@code per2.} prefix.
*/
private static Map<String, String> mergeVariables(final SendEmailRequest.Message message) {
final Map<String, String> merged = new HashMap<>();
if (message.variables() != null) {
merged.putAll(message.variables());
}
final List<SendEmailRequest.Recipient> recipients = message.recipients();
for (int i = 0; i < recipients.size(); i++) {
final String prefix = "per" + (i + 1) + ".";
final Map<String, String> vars = recipients.get(i).variableToValue();
if (vars != null) {
vars.forEach((key, value) -> merged.put(prefix + key, value));
}
}
return merged;
}
}
Loading