From 4ff1741856e78112ec252cafc73c4be47ccea092 Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Mon, 18 May 2026 18:41:57 -0600
Subject: [PATCH 1/2] fix: make mail truly optional and clarify quick-start
docs (#310)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
MailService now degrades gracefully when no JavaMailSender bean is
available (typically because spring.mail.host is unset) — operations
log a warning and return instead of failing application startup.
README quick-start updates:
- Step 1: list spring-boot-starter-oauth2-client as required (it is
unconditionally wired into the security chain) and pin spring-retry
to 2.0.12 to match what the library is built against.
- Step 4: clarify that mail config is genuinely optional now, with
email-dependent features degrading to a warning.
- Step 9: clarify the library only ships email templates; user-facing
pages must come from the demo app or be supplied by the consumer.
Reported in #310 by @katharinebrinker.
---
README.md | 33 ++++++++---
.../spring/user/mail/MailService.java | 58 ++++++++++++++-----
.../spring/user/mail/MailServiceTest.java | 44 ++++++++++++--
3 files changed, 108 insertions(+), 27 deletions(-)
diff --git a/README.md b/README.md
index 2f4edbc..5f899a5 100644
--- a/README.md
+++ b/README.md
@@ -239,9 +239,14 @@ Follow these steps to get up and running with the Spring User Framework in your
org.springframework.boot
spring-boot-starter-security
+
+ org.springframework.boot
+ spring-boot-starter-oauth2-client
+
org.springframework.retry
spring-retry
+ 2.0.12
```
@@ -251,9 +256,14 @@ Follow these steps to get up and running with the Spring User Framework in your
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
- implementation 'org.springframework.retry:spring-retry'
+ implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
+ implementation 'org.springframework.retry:spring-retry:2.0.12'
```
+ **Notes:**
+ - `spring-boot-starter-oauth2-client` is required even if you don't plan to enable social login. The framework's security chain wires OAuth2 user services at startup; the dependency must be on the classpath so the classes resolve. OAuth2 login itself remains disabled by default (`spring.security.oauth2.enabled=false`).
+ - `spring-retry` needs an explicit version because Spring Boot's BOM may not manage this artifact. The version shown matches what the framework is built against.
+
### Step 2: Database Configuration
Configure your database in `application.yml`. The framework supports all databases compatible with Spring Data JPA:
@@ -298,7 +308,7 @@ spring:
### Step 4: Email Configuration (Optional but Recommended)
-For password reset and email verification features:
+If `spring.mail.host` is not configured, the framework starts cleanly but any feature that would send mail (password reset, registration verification) logs a warning and silently skips the send. Configure mail when you want those features to actually deliver messages:
```yaml
spring:
@@ -384,17 +394,24 @@ public class AppUserProfile extends BaseUserProfile {
- Navigate to `/user/login.html`
- Use the credentials you just created
-### Step 9: Customize Pages (Optional)
+### Step 9: Customize Pages (Required for user-facing pages)
-The framework provides default HTML templates, but you can override them:
+The framework ships email templates (`templates/mail/*.html`) but does **not** ship the user-facing HTML pages (login, register, forgot-password, etc). You provide those yourself, typically by copying the reference set from the demo app and styling them to match your app.
-1. **Create custom templates** in `src/main/resources/templates/user/`:
+1. **Grab the reference templates** from [SpringUserFrameworkDemoApp/src/main/resources/templates/user/](https://github.com/devondragon/SpringUserFrameworkDemoApp/tree/main/src/main/resources/templates/user) and drop them into `src/main/resources/templates/user/` in your project:
- `login.html` - Login page
- `register.html` - Registration page
- - `forgot-password.html` - Password reset page
- - And more...
+ - `forgot-password.html` - Password reset request
+ - `forgot-password-change.html` - Password reset form
+ - `update-password.html` - Authenticated password change
+ - `update-user.html` - Profile update
+ - `registration-complete.html`, `registration-pending-verification.html`, `request-new-verification-email.html`, `forgot-password-pending-verification.html`, `delete-account.html`
+
+ The demo's templates use a Thymeleaf layout (`layout.html`) and shared fragments. If you don't want that, strip the `layout:decorate` / `th:fragment` references and inline the markup.
+
+2. **Use your own CSS** by adding stylesheets to `src/main/resources/static/css/`.
-2. **Use your own CSS** by adding stylesheets to `src/main/resources/static/css/`
+The framework only requires that the templates exist at the URLs configured under `user.security.*` (e.g. `loginPageURI`, `registrationURI`) and post back to the matching `/user/*` API endpoints; the HTML structure inside them is yours.
### Complete Example Configuration
diff --git a/src/main/java/com/digitalsanctuary/spring/user/mail/MailService.java b/src/main/java/com/digitalsanctuary/spring/user/mail/MailService.java
index a5419de..71d9558 100644
--- a/src/main/java/com/digitalsanctuary/spring/user/mail/MailService.java
+++ b/src/main/java/com/digitalsanctuary/spring/user/mail/MailService.java
@@ -1,6 +1,7 @@
package com.digitalsanctuary.spring.user.mail;
import java.util.Map;
+import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.MailException;
import org.springframework.mail.javamail.JavaMailSender;
@@ -17,13 +18,16 @@
/**
* The MailService provides outbound email sending services on top of the Spring mail framework, and leverages Thymeleaf templates for rich dynamic
* emails.
+ *
+ * Email is treated as optional: if no {@link JavaMailSender} bean is available (typically because {@code spring.mail.host} is not configured),
+ * send operations log a warning and return without throwing, so the application starts and runs normally with email-dependent features degraded.
*/
@Slf4j
@Service
public class MailService {
- /** The mail sender. */
- private final JavaMailSender mailSender;
+ /** Provider for the mail sender — resolved lazily so the bean is optional. */
+ private final ObjectProvider mailSenderProvider;
/** The mail content builder. */
private final MailContentBuilder mailContentBuilder;
@@ -35,14 +39,28 @@ public class MailService {
/**
* Instantiates a new mail service.
*
- * @param mailSender the mail sender
+ * @param mailSenderProvider provider for the mail sender; may resolve to null when mail is not configured
* @param mailContentBuilder the mail content builder
*/
- public MailService(JavaMailSender mailSender, MailContentBuilder mailContentBuilder) {
- this.mailSender = mailSender;
+ public MailService(ObjectProvider mailSenderProvider, MailContentBuilder mailContentBuilder) {
+ this.mailSenderProvider = mailSenderProvider;
this.mailContentBuilder = mailContentBuilder;
}
+ /**
+ * Resolve the {@link JavaMailSender}, or log a warning and return null when none is available.
+ *
+ * @param to the recipient (for log context only)
+ * @return the sender, or {@code null} if not configured
+ */
+ private JavaMailSender resolveMailSender(String to) {
+ JavaMailSender sender = mailSenderProvider.getIfAvailable();
+ if (sender == null) {
+ log.warn("Email send to '{}' skipped: JavaMailSender is not configured. Set 'spring.mail.host' to enable email sending.", to);
+ }
+ return sender;
+ }
+
/**
* Send a simple plain text email.
*
@@ -51,11 +69,16 @@ public MailService(JavaMailSender mailSender, MailContentBuilder mailContentBuil
* @param text the text to include as the email message body
*/
@Async
- @Retryable(retryFor = {MailException.class}, maxAttempts = 3,
+ @Retryable(retryFor = {MailException.class}, maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2))
public void sendSimpleMessage(String to, String subject, String text) {
log.debug("Attempting to send simple email to: {}", to);
-
+
+ JavaMailSender sender = resolveMailSender(to);
+ if (sender == null) {
+ return;
+ }
+
MimeMessagePreparator messagePreparator = mimeMessage -> {
MimeMessageHelper messageHelper = new MimeMessageHelper(mimeMessage);
messageHelper.setFrom(fromAddress);
@@ -64,7 +87,7 @@ public void sendSimpleMessage(String to, String subject, String text) {
messageHelper.setText(text, true);
};
- mailSender.send(messagePreparator);
+ sender.send(messagePreparator);
log.debug("Successfully sent simple email to: {}", to);
}
@@ -77,11 +100,16 @@ public void sendSimpleMessage(String to, String subject, String text) {
* @param templatePath the file name, or path and name, for the Thymeleaf template to use to build the dynamic email
*/
@Async
- @Retryable(retryFor = {MailException.class}, maxAttempts = 3,
+ @Retryable(retryFor = {MailException.class}, maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2))
public void sendTemplateMessage(String to, String subject, Map variables, String templatePath) {
log.debug("Attempting to send template email to: {}, template: {}", to, templatePath);
-
+
+ JavaMailSender sender = resolveMailSender(to);
+ if (sender == null) {
+ return;
+ }
+
MimeMessagePreparator messagePreparator = mimeMessage -> {
MimeMessageHelper messageHelper = new MimeMessageHelper(mimeMessage);
messageHelper.setFrom(fromAddress);
@@ -92,8 +120,8 @@ public void sendTemplateMessage(String to, String subject, Map v
String content = mailContentBuilder.build(templatePath, context);
messageHelper.setText(content, true);
};
-
- mailSender.send(messagePreparator);
+
+ sender.send(messagePreparator);
log.debug("Successfully sent template email to: {}", to);
}
@@ -107,7 +135,7 @@ public void sendTemplateMessage(String to, String subject, Map v
*/
@Recover
public void recoverSendSimpleMessage(MailException ex, String to, String subject, String text) {
- log.error("Failed to send simple email to {} after all retry attempts. Subject: '{}'. Error: {}",
+ log.error("Failed to send simple email to {} after all retry attempts. Subject: '{}'. Error: {}",
to, subject, ex.getMessage());
}
@@ -121,9 +149,9 @@ public void recoverSendSimpleMessage(MailException ex, String to, String subject
* @param templatePath the template path
*/
@Recover
- public void recoverSendTemplateMessage(MailException ex, String to, String subject,
+ public void recoverSendTemplateMessage(MailException ex, String to, String subject,
Map variables, String templatePath) {
- log.error("Failed to send template email to {} after all retry attempts. Subject: '{}', Template: '{}'. Error: {}",
+ log.error("Failed to send template email to {} after all retry attempts. Subject: '{}', Template: '{}'. Error: {}",
to, subject, templatePath, ex.getMessage());
}
}
diff --git a/src/test/java/com/digitalsanctuary/spring/user/mail/MailServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/mail/MailServiceTest.java
index a7a4f97..77dba06 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/mail/MailServiceTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/mail/MailServiceTest.java
@@ -17,9 +17,9 @@
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
-import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.beans.factory.ObjectProvider;
import org.springframework.mail.MailException;
import org.springframework.mail.MailSendException;
import org.springframework.mail.javamail.JavaMailSender;
@@ -39,13 +39,15 @@ class MailServiceTest {
@Mock
private JavaMailSender mailSender;
+ @Mock
+ private ObjectProvider mailSenderProvider;
+
@Mock
private MailContentBuilder mailContentBuilder;
@Mock
private MimeMessage mimeMessage;
- @InjectMocks
private MailService mailService;
private static final String FROM_ADDRESS = "noreply@example.com";
@@ -54,9 +56,11 @@ class MailServiceTest {
@BeforeEach
void setUp() {
- // Set the from address via reflection since it's a @Value field
+ // Provider returns the mock mailSender by default; individual tests can override to simulate missing config.
+ lenient().when(mailSenderProvider.getIfAvailable()).thenReturn(mailSender);
+ mailService = new MailService(mailSenderProvider, mailContentBuilder);
ReflectionTestUtils.setField(mailService, "fromAddress", FROM_ADDRESS);
-
+
// Setup default mock behavior
lenient().when(mailSender.createMimeMessage()).thenReturn(mimeMessage);
}
@@ -506,6 +510,38 @@ void shouldHandleTemplateBuilderException() throws Exception {
}
}
+ @Nested
+ @DisplayName("Missing JavaMailSender Tests")
+ class MissingMailSenderTests {
+
+ @Test
+ @DisplayName("sendSimpleMessage should no-op when JavaMailSender is not configured")
+ void sendSimpleMessageNoOpsWhenSenderMissing() {
+ // Given
+ when(mailSenderProvider.getIfAvailable()).thenReturn(null);
+
+ // When
+ mailService.sendSimpleMessage(TO_ADDRESS, SUBJECT, "Body");
+
+ // Then
+ verify(mailSender, never()).send(any(MimeMessagePreparator.class));
+ }
+
+ @Test
+ @DisplayName("sendTemplateMessage should no-op when JavaMailSender is not configured")
+ void sendTemplateMessageNoOpsWhenSenderMissing() {
+ // Given
+ when(mailSenderProvider.getIfAvailable()).thenReturn(null);
+
+ // When
+ mailService.sendTemplateMessage(TO_ADDRESS, SUBJECT, new HashMap<>(), "email/test");
+
+ // Then
+ verify(mailSender, never()).send(any(MimeMessagePreparator.class));
+ verify(mailContentBuilder, never()).build(anyString(), any(Context.class));
+ }
+ }
+
@Nested
@DisplayName("Integration with MimeMessageHelper Tests")
class MimeMessageHelperIntegrationTests {
From 389aa7bbd5a58f10597e34b4fee576041b386c8a Mon Sep 17 00:00:00 2001
From: Devon Hillard
Date: Mon, 18 May 2026 18:49:25 -0600
Subject: [PATCH 2/2] fix: address PR 311 review feedback
- MailService: switch to @PostConstruct to cache JavaMailSender once at
startup; warning logged a single time without PII instead of per-call
with recipient address (GDPR/noise concern raised by Copilot review).
- MailServiceTest: call init() after construction to simulate
@PostConstruct; no-sender tests use a dedicated service instance.
- README: clarify spring.security.oauth2.enabled is a framework
property (not a standard Spring Security key) and is opt-in only.
---
README.md | 2 +-
.../spring/user/mail/MailService.java | 41 +++++++++----------
.../spring/user/mail/MailServiceTest.java | 28 +++++++------
3 files changed, 35 insertions(+), 36 deletions(-)
diff --git a/README.md b/README.md
index 5f899a5..1d8a9ff 100644
--- a/README.md
+++ b/README.md
@@ -261,7 +261,7 @@ Follow these steps to get up and running with the Spring User Framework in your
```
**Notes:**
- - `spring-boot-starter-oauth2-client` is required even if you don't plan to enable social login. The framework's security chain wires OAuth2 user services at startup; the dependency must be on the classpath so the classes resolve. OAuth2 login itself remains disabled by default (`spring.security.oauth2.enabled=false`).
+ - `spring-boot-starter-oauth2-client` is required even if you don't plan to use social login. The framework's security chain wires OAuth2 user services at startup; the dependency must be on the classpath so the classes resolve. The OAuth2 login flow itself is disabled by default and opt-in — set `spring.security.oauth2.enabled=true` in your application properties only when you configure OAuth2 provider credentials (this is a framework property, not a standard Spring Security key).
- `spring-retry` needs an explicit version because Spring Boot's BOM may not manage this artifact. The version shown matches what the framework is built against.
### Step 2: Database Configuration
diff --git a/src/main/java/com/digitalsanctuary/spring/user/mail/MailService.java b/src/main/java/com/digitalsanctuary/spring/user/mail/MailService.java
index 71d9558..37a6f50 100644
--- a/src/main/java/com/digitalsanctuary/spring/user/mail/MailService.java
+++ b/src/main/java/com/digitalsanctuary/spring/user/mail/MailService.java
@@ -1,6 +1,7 @@
package com.digitalsanctuary.spring.user.mail;
import java.util.Map;
+import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.MailException;
@@ -20,18 +21,20 @@
* emails.
*
* Email is treated as optional: if no {@link JavaMailSender} bean is available (typically because {@code spring.mail.host} is not configured),
- * send operations log a warning and return without throwing, so the application starts and runs normally with email-dependent features degraded.
+ * a single warning is logged at startup and all send operations silently no-op, so the application starts and runs normally with email-dependent
+ * features degraded.
*/
@Slf4j
@Service
public class MailService {
- /** Provider for the mail sender — resolved lazily so the bean is optional. */
private final ObjectProvider mailSenderProvider;
- /** The mail content builder. */
private final MailContentBuilder mailContentBuilder;
+ /** Resolved once at startup; null when JavaMailSender is not configured. */
+ private JavaMailSender resolvedSender;
+
/** The from address. */
@Value("${user.mail.fromAddress}")
private String fromAddress;
@@ -48,17 +51,15 @@ public MailService(ObjectProvider mailSenderProvider, MailConten
}
/**
- * Resolve the {@link JavaMailSender}, or log a warning and return null when none is available.
- *
- * @param to the recipient (for log context only)
- * @return the sender, or {@code null} if not configured
+ * Resolves and caches the {@link JavaMailSender} once at startup. Logs a single warning when no sender is available so operators are informed
+ * without flooding logs during normal operation.
*/
- private JavaMailSender resolveMailSender(String to) {
- JavaMailSender sender = mailSenderProvider.getIfAvailable();
- if (sender == null) {
- log.warn("Email send to '{}' skipped: JavaMailSender is not configured. Set 'spring.mail.host' to enable email sending.", to);
+ @PostConstruct
+ void init() {
+ resolvedSender = mailSenderProvider.getIfAvailable();
+ if (resolvedSender == null) {
+ log.warn("JavaMailSender is not configured — email sending is disabled. Set 'spring.mail.host' to enable.");
}
- return sender;
}
/**
@@ -72,12 +73,10 @@ private JavaMailSender resolveMailSender(String to) {
@Retryable(retryFor = {MailException.class}, maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2))
public void sendSimpleMessage(String to, String subject, String text) {
- log.debug("Attempting to send simple email to: {}", to);
-
- JavaMailSender sender = resolveMailSender(to);
- if (sender == null) {
+ if (resolvedSender == null) {
return;
}
+ log.debug("Attempting to send simple email to: {}", to);
MimeMessagePreparator messagePreparator = mimeMessage -> {
MimeMessageHelper messageHelper = new MimeMessageHelper(mimeMessage);
@@ -87,7 +86,7 @@ public void sendSimpleMessage(String to, String subject, String text) {
messageHelper.setText(text, true);
};
- sender.send(messagePreparator);
+ resolvedSender.send(messagePreparator);
log.debug("Successfully sent simple email to: {}", to);
}
@@ -103,12 +102,10 @@ public void sendSimpleMessage(String to, String subject, String text) {
@Retryable(retryFor = {MailException.class}, maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2))
public void sendTemplateMessage(String to, String subject, Map variables, String templatePath) {
- log.debug("Attempting to send template email to: {}, template: {}", to, templatePath);
-
- JavaMailSender sender = resolveMailSender(to);
- if (sender == null) {
+ if (resolvedSender == null) {
return;
}
+ log.debug("Attempting to send template email to: {}, template: {}", to, templatePath);
MimeMessagePreparator messagePreparator = mimeMessage -> {
MimeMessageHelper messageHelper = new MimeMessageHelper(mimeMessage);
@@ -121,7 +118,7 @@ public void sendTemplateMessage(String to, String subject, Map v
messageHelper.setText(content, true);
};
- sender.send(messagePreparator);
+ resolvedSender.send(messagePreparator);
log.debug("Successfully sent template email to: {}", to);
}
diff --git a/src/test/java/com/digitalsanctuary/spring/user/mail/MailServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/mail/MailServiceTest.java
index 77dba06..b2dcb7f 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/mail/MailServiceTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/mail/MailServiceTest.java
@@ -56,9 +56,10 @@ class MailServiceTest {
@BeforeEach
void setUp() {
- // Provider returns the mock mailSender by default; individual tests can override to simulate missing config.
+ // Provider returns the mock mailSender by default.
lenient().when(mailSenderProvider.getIfAvailable()).thenReturn(mailSender);
mailService = new MailService(mailSenderProvider, mailContentBuilder);
+ mailService.init(); // simulate @PostConstruct — caches the resolved sender
ReflectionTestUtils.setField(mailService, "fromAddress", FROM_ADDRESS);
// Setup default mock behavior
@@ -514,29 +515,30 @@ void shouldHandleTemplateBuilderException() throws Exception {
@DisplayName("Missing JavaMailSender Tests")
class MissingMailSenderTests {
+ private MailService unconfiguredMailService;
+
+ @BeforeEach
+ void setUpUnconfigured() {
+ // Separate service instance where the sender is absent from startup.
+ when(mailSenderProvider.getIfAvailable()).thenReturn(null);
+ unconfiguredMailService = new MailService(mailSenderProvider, mailContentBuilder);
+ unconfiguredMailService.init();
+ ReflectionTestUtils.setField(unconfiguredMailService, "fromAddress", FROM_ADDRESS);
+ }
+
@Test
@DisplayName("sendSimpleMessage should no-op when JavaMailSender is not configured")
void sendSimpleMessageNoOpsWhenSenderMissing() {
- // Given
- when(mailSenderProvider.getIfAvailable()).thenReturn(null);
-
- // When
- mailService.sendSimpleMessage(TO_ADDRESS, SUBJECT, "Body");
+ unconfiguredMailService.sendSimpleMessage(TO_ADDRESS, SUBJECT, "Body");
- // Then
verify(mailSender, never()).send(any(MimeMessagePreparator.class));
}
@Test
@DisplayName("sendTemplateMessage should no-op when JavaMailSender is not configured")
void sendTemplateMessageNoOpsWhenSenderMissing() {
- // Given
- when(mailSenderProvider.getIfAvailable()).thenReturn(null);
+ unconfiguredMailService.sendTemplateMessage(TO_ADDRESS, SUBJECT, new HashMap<>(), "email/test");
- // When
- mailService.sendTemplateMessage(TO_ADDRESS, SUBJECT, new HashMap<>(), "email/test");
-
- // Then
verify(mailSender, never()).send(any(MimeMessagePreparator.class));
verify(mailContentBuilder, never()).build(anyString(), any(Context.class));
}