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 {