diff --git a/README.md b/README.md
index 2f4edbc..1d8a9ff 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 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
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..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,8 @@
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;
import org.springframework.mail.javamail.JavaMailSender;
@@ -17,17 +19,22 @@
/**
* 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),
+ * 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 {
- /** The mail sender. */
- private final JavaMailSender mailSender;
+ 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;
@@ -35,14 +42,26 @@ 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;
}
+ /**
+ * 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.
+ */
+ @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.");
+ }
+ }
+
/**
* Send a simple plain text email.
*
@@ -51,11 +70,14 @@ 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) {
+ if (resolvedSender == null) {
+ return;
+ }
log.debug("Attempting to send simple email to: {}", to);
-
+
MimeMessagePreparator messagePreparator = mimeMessage -> {
MimeMessageHelper messageHelper = new MimeMessageHelper(mimeMessage);
messageHelper.setFrom(fromAddress);
@@ -64,7 +86,7 @@ public void sendSimpleMessage(String to, String subject, String text) {
messageHelper.setText(text, true);
};
- mailSender.send(messagePreparator);
+ resolvedSender.send(messagePreparator);
log.debug("Successfully sent simple email to: {}", to);
}
@@ -77,11 +99,14 @@ 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) {
+ if (resolvedSender == null) {
+ return;
+ }
log.debug("Attempting to send template email to: {}, template: {}", to, templatePath);
-
+
MimeMessagePreparator messagePreparator = mimeMessage -> {
MimeMessageHelper messageHelper = new MimeMessageHelper(mimeMessage);
messageHelper.setFrom(fromAddress);
@@ -92,8 +117,8 @@ public void sendTemplateMessage(String to, String subject, Map v
String content = mailContentBuilder.build(templatePath, context);
messageHelper.setText(content, true);
};
-
- mailSender.send(messagePreparator);
+
+ resolvedSender.send(messagePreparator);
log.debug("Successfully sent template email to: {}", to);
}
@@ -107,7 +132,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 +146,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..b2dcb7f 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,12 @@ class MailServiceTest {
@BeforeEach
void setUp() {
- // Set the from address via reflection since it's a @Value field
+ // 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
lenient().when(mailSender.createMimeMessage()).thenReturn(mimeMessage);
}
@@ -506,6 +511,39 @@ void shouldHandleTemplateBuilderException() throws Exception {
}
}
+ @Nested
+ @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() {
+ unconfiguredMailService.sendSimpleMessage(TO_ADDRESS, SUBJECT, "Body");
+
+ verify(mailSender, never()).send(any(MimeMessagePreparator.class));
+ }
+
+ @Test
+ @DisplayName("sendTemplateMessage should no-op when JavaMailSender is not configured")
+ void sendTemplateMessageNoOpsWhenSenderMissing() {
+ unconfiguredMailService.sendTemplateMessage(TO_ADDRESS, SUBJECT, new HashMap<>(), "email/test");
+
+ verify(mailSender, never()).send(any(MimeMessagePreparator.class));
+ verify(mailContentBuilder, never()).build(anyString(), any(Context.class));
+ }
+ }
+
@Nested
@DisplayName("Integration with MimeMessageHelper Tests")
class MimeMessageHelperIntegrationTests {