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
33 changes: 25 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,14 @@ Follow these steps to get up and running with the Spring User Framework in your
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
<version>2.0.12</version>
</dependency>
```

Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -17,32 +19,49 @@
/**
* The MailService provides outbound email sending services on top of the Spring mail framework, and leverages Thymeleaf templates for rich dynamic
* emails.
*
* <p>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.</p>
*/
@Slf4j
@Service
public class MailService {

/** The mail sender. */
private final JavaMailSender mailSender;
private final ObjectProvider<JavaMailSender> 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;

/**
* 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<JavaMailSender> 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.
*
Expand All @@ -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);
Expand All @@ -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);
}

Expand All @@ -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<String, Object> 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);
Expand All @@ -92,8 +117,8 @@ public void sendTemplateMessage(String to, String subject, Map<String, Object> 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);
}

Expand All @@ -107,7 +132,7 @@ public void sendTemplateMessage(String to, String subject, Map<String, Object> 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());
}

Expand All @@ -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<String, Object> 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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -39,13 +39,15 @@ class MailServiceTest {
@Mock
private JavaMailSender mailSender;

@Mock
private ObjectProvider<JavaMailSender> mailSenderProvider;

@Mock
private MailContentBuilder mailContentBuilder;

@Mock
private MimeMessage mimeMessage;

@InjectMocks
private MailService mailService;

private static final String FROM_ADDRESS = "noreply@example.com";
Expand All @@ -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);
}
Expand Down Expand Up @@ -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 {
Expand Down
Loading